jottacloud: use new auth method used by official client
This commit is contained in:
parent
d72f3e31c0
commit
4195bd7880
2 changed files with 106 additions and 146 deletions
|
@ -46,13 +46,26 @@ func (t Time) String() string { return time.Time(t).Format(timeFormat) }
|
||||||
// APIString returns Time string in Jottacloud API format
|
// APIString returns Time string in Jottacloud API format
|
||||||
func (t Time) APIString() string { return time.Time(t).Format(apiTimeFormat) }
|
func (t Time) APIString() string { return time.Time(t).Format(apiTimeFormat) }
|
||||||
|
|
||||||
|
// LoginToken is struct representing the login token generated in the WebUI
|
||||||
|
type LoginToken struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Realm string `json:"realm"`
|
||||||
|
WellKnownLink string `json:"well_known_link"`
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}
|
||||||
|
|
||||||
// TokenJSON is the struct representing the HTTP response from OAuth2
|
// TokenJSON is the struct representing the HTTP response from OAuth2
|
||||||
// providers returning a token in JSON form.
|
// providers returning a token in JSON form.
|
||||||
type TokenJSON struct {
|
type TokenJSON struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
TokenType string `json:"token_type"`
|
ExpiresIn int32 `json:"expires_in"` // at least PayPal returns string, while most return number
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshExpiresIn int32 `json:"refresh_expires_in"`
|
||||||
ExpiresIn int32 `json:"expires_in"` // at least PayPal returns string, while most return number
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
NotBeforePolicy int32 `json:"not-before-policy"`
|
||||||
|
SessionState string `json:"session_state"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON structures returned by new API
|
// JSON structures returned by new API
|
||||||
|
|
|
@ -4,12 +4,13 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
@ -25,7 +26,6 @@ import (
|
||||||
"github.com/rclone/rclone/fs/config"
|
"github.com/rclone/rclone/fs/config"
|
||||||
"github.com/rclone/rclone/fs/config/configmap"
|
"github.com/rclone/rclone/fs/config/configmap"
|
||||||
"github.com/rclone/rclone/fs/config/configstruct"
|
"github.com/rclone/rclone/fs/config/configstruct"
|
||||||
"github.com/rclone/rclone/fs/config/obscure"
|
|
||||||
"github.com/rclone/rclone/fs/encodings"
|
"github.com/rclone/rclone/fs/encodings"
|
||||||
"github.com/rclone/rclone/fs/fserrors"
|
"github.com/rclone/rclone/fs/fserrors"
|
||||||
"github.com/rclone/rclone/fs/fshttp"
|
"github.com/rclone/rclone/fs/fshttp"
|
||||||
|
@ -41,29 +41,25 @@ const enc = encodings.JottaCloud
|
||||||
|
|
||||||
// Globals
|
// Globals
|
||||||
const (
|
const (
|
||||||
minSleep = 10 * time.Millisecond
|
minSleep = 10 * time.Millisecond
|
||||||
maxSleep = 2 * time.Second
|
maxSleep = 2 * time.Second
|
||||||
decayConstant = 2 // bigger for slower decay, exponential
|
decayConstant = 2 // bigger for slower decay, exponential
|
||||||
defaultDevice = "Jotta"
|
defaultDevice = "Jotta"
|
||||||
defaultMountpoint = "Archive"
|
defaultMountpoint = "Archive"
|
||||||
rootURL = "https://www.jottacloud.com/jfs/"
|
rootURL = "https://www.jottacloud.com/jfs/"
|
||||||
apiURL = "https://api.jottacloud.com/"
|
apiURL = "https://api.jottacloud.com/"
|
||||||
baseURL = "https://www.jottacloud.com/"
|
baseURL = "https://www.jottacloud.com/"
|
||||||
tokenURL = "https://api.jottacloud.com/auth/v1/token"
|
tokenURL = "https://id.jottacloud.com/auth/realms/jottacloud/protocol/openid-connect/token"
|
||||||
registerURL = "https://api.jottacloud.com/auth/v1/register"
|
cachePrefix = "rclone-jcmd5-"
|
||||||
cachePrefix = "rclone-jcmd5-"
|
configDevice = "device"
|
||||||
rcloneClientID = "nibfk8biu12ju7hpqomr8b1e40"
|
configMountpoint = "mountpoint"
|
||||||
rcloneEncryptedClientSecret = "Vp8eAv7eVElMnQwN-kgU9cbhgApNDaMqWdlDi5qFydlQoji4JBxrGMF2"
|
configVersion = 1
|
||||||
configClientID = "client_id"
|
|
||||||
configClientSecret = "client_secret"
|
|
||||||
configDevice = "device"
|
|
||||||
configMountpoint = "mountpoint"
|
|
||||||
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Description of how to auth for this app for a personal account
|
// Description of how to auth for this app for a personal account
|
||||||
oauthConfig = &oauth2.Config{
|
oauthConfig = &oauth2.Config{
|
||||||
|
ClientID: "jottacli",
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
AuthURL: tokenURL,
|
AuthURL: tokenURL,
|
||||||
TokenURL: tokenURL,
|
TokenURL: tokenURL,
|
||||||
|
@ -81,43 +77,38 @@ func init() {
|
||||||
NewFs: NewFs,
|
NewFs: NewFs,
|
||||||
Config: func(name string, m configmap.Mapper) {
|
Config: func(name string, m configmap.Mapper) {
|
||||||
ctx := context.TODO()
|
ctx := context.TODO()
|
||||||
tokenString, ok := m.Get("token")
|
|
||||||
if ok && tokenString != "" {
|
|
||||||
fmt.Printf("Already have a token - refresh?\n")
|
|
||||||
if !config.Confirm(false) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := rest.NewClient(fshttp.NewClient(fs.Config))
|
refresh := false
|
||||||
fmt.Printf("\nDo you want to create a machine specific API key?\n\nRclone has it's own Jottacloud API KEY which works fine as long as one only uses rclone on a single machine. When you want to use rclone with this account on more than one machine it's recommended to create a machine specific API key. These keys can NOT be shared between machines.\n\n")
|
if version, ok := m.Get("configVersion"); ok {
|
||||||
if config.Confirm(false) {
|
ver, err := strconv.Atoi(version)
|
||||||
deviceRegistration, err := registerDevice(ctx, srv)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to register device: %v", err)
|
log.Fatalf("Failed to parse config version - corrupted config")
|
||||||
}
|
}
|
||||||
|
refresh = ver != configVersion
|
||||||
m.Set(configClientID, deviceRegistration.ClientID)
|
} else {
|
||||||
m.Set(configClientSecret, obscure.MustObscure(deviceRegistration.ClientSecret))
|
refresh = true
|
||||||
fs.Debugf(nil, "Got clientID '%s' and clientSecret '%s'", deviceRegistration.ClientID, deviceRegistration.ClientSecret)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, ok := m.Get(configClientID)
|
if refresh {
|
||||||
if !ok {
|
fmt.Printf("Config outdated - refreshing\n")
|
||||||
clientID = rcloneClientID
|
} else {
|
||||||
|
tokenString, ok := m.Get("token")
|
||||||
|
if ok && tokenString != "" {
|
||||||
|
fmt.Printf("Already have a token - refresh?\n")
|
||||||
|
if !config.Confirm(false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
clientSecret, ok := m.Get(configClientSecret)
|
|
||||||
if !ok {
|
|
||||||
clientSecret = rcloneEncryptedClientSecret
|
|
||||||
}
|
|
||||||
oauthConfig.ClientID = clientID
|
|
||||||
oauthConfig.ClientSecret = obscure.MustReveal(clientSecret)
|
|
||||||
|
|
||||||
fmt.Printf("Username> ")
|
clientConfig := *fs.Config
|
||||||
username := config.ReadLine()
|
clientConfig.UserAgent = "JottaCli 0.6.18626 windows-amd64"
|
||||||
password := config.GetPassword("Your Jottacloud password is only required during setup and will not be stored.")
|
srv := rest.NewClient(fshttp.NewClient(&clientConfig))
|
||||||
|
|
||||||
token, err := doAuth(ctx, srv, username, password)
|
fmt.Printf("Login Token> ")
|
||||||
|
loginToken := config.ReadLine()
|
||||||
|
|
||||||
|
token, err := doAuth(ctx, srv, loginToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to get oauth token: %s", err)
|
log.Fatalf("Failed to get oauth token: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -143,6 +134,8 @@ func init() {
|
||||||
m.Set(configDevice, device)
|
m.Set(configDevice, device)
|
||||||
m.Set(configMountpoint, mountpoint)
|
m.Set(configMountpoint, mountpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.Set("configVersion", strconv.Itoa(configVersion))
|
||||||
},
|
},
|
||||||
Options: []fs.Option{{
|
Options: []fs.Option{{
|
||||||
Name: "md5_memory_limit",
|
Name: "md5_memory_limit",
|
||||||
|
@ -249,67 +242,51 @@ func shouldRetry(resp *http.Response, err error) (bool, error) {
|
||||||
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
|
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerDevice register a new device for use with the jottacloud API
|
|
||||||
func registerDevice(ctx context.Context, srv *rest.Client) (reg *api.DeviceRegistrationResponse, err error) {
|
|
||||||
// random generator to generate random device names
|
|
||||||
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
||||||
randonDeviceNamePartLength := 21
|
|
||||||
randomDeviceNamePart := make([]byte, randonDeviceNamePartLength)
|
|
||||||
for i := range randomDeviceNamePart {
|
|
||||||
randomDeviceNamePart[i] = charset[seededRand.Intn(len(charset))]
|
|
||||||
}
|
|
||||||
randomDeviceName := "rclone-" + string(randomDeviceNamePart)
|
|
||||||
fs.Debugf(nil, "Trying to register device '%s'", randomDeviceName)
|
|
||||||
|
|
||||||
values := url.Values{}
|
|
||||||
values.Set("device_id", randomDeviceName)
|
|
||||||
|
|
||||||
opts := rest.Opts{
|
|
||||||
Method: "POST",
|
|
||||||
RootURL: registerURL,
|
|
||||||
ContentType: "application/x-www-form-urlencoded",
|
|
||||||
ExtraHeaders: map[string]string{"Authorization": "Bearer c2xrZmpoYWRsZmFramhkc2xma2phaHNkbGZramhhc2xkZmtqaGFzZGxrZmpobGtq"},
|
|
||||||
Parameters: values,
|
|
||||||
}
|
|
||||||
|
|
||||||
var deviceRegistration *api.DeviceRegistrationResponse
|
|
||||||
_, err = srv.CallJSON(ctx, &opts, nil, &deviceRegistration)
|
|
||||||
return deviceRegistration, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// doAuth runs the actual token request
|
// doAuth runs the actual token request
|
||||||
func doAuth(ctx context.Context, srv *rest.Client, username, password string) (token oauth2.Token, err error) {
|
func doAuth(ctx context.Context, srv *rest.Client, loginTokenBase64 string) (token oauth2.Token, err error) {
|
||||||
|
loginTokenBytes, err := base64.StdEncoding.DecodeString(loginTokenBase64)
|
||||||
|
if err != nil {
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginToken api.LoginToken
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(loginTokenBytes))
|
||||||
|
err = decoder.Decode(&loginToken)
|
||||||
|
if err != nil {
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// we don't seem to need any data from this link but the API is not happy if skip it
|
||||||
|
opts := rest.Opts{
|
||||||
|
Method: "GET",
|
||||||
|
RootURL: loginToken.WellKnownLink,
|
||||||
|
NoResponse: true,
|
||||||
|
}
|
||||||
|
_, err = srv.Call(ctx, &opts)
|
||||||
|
if err != nil {
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
// prepare out token request with username and password
|
// prepare out token request with username and password
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
values.Set("grant_type", "PASSWORD")
|
values.Set("client_id", "jottacli")
|
||||||
values.Set("password", password)
|
values.Set("grant_type", "password")
|
||||||
values.Set("username", username)
|
values.Set("password", loginToken.AuthToken)
|
||||||
values.Set("client_id", oauthConfig.ClientID)
|
values.Set("scope", "offline_access+openid")
|
||||||
values.Set("client_secret", oauthConfig.ClientSecret)
|
values.Set("username", loginToken.Username)
|
||||||
opts := rest.Opts{
|
values.Encode()
|
||||||
|
opts = rest.Opts{
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
RootURL: oauthConfig.Endpoint.AuthURL,
|
RootURL: oauthConfig.Endpoint.AuthURL,
|
||||||
ContentType: "application/x-www-form-urlencoded",
|
ContentType: "application/x-www-form-urlencoded",
|
||||||
Parameters: values,
|
Body: strings.NewReader(values.Encode()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// do the first request
|
// do the first request
|
||||||
var jsonToken api.TokenJSON
|
var jsonToken api.TokenJSON
|
||||||
resp, err := srv.CallJSON(ctx, &opts, nil, &jsonToken)
|
_, err = srv.CallJSON(ctx, &opts, nil, &jsonToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// if 2fa is enabled the first request is expected to fail. We will do another request with the 2fa code as an additional http header
|
return token, err
|
||||||
if resp != nil {
|
|
||||||
if resp.Header.Get("X-JottaCloud-OTP") == "required; SMS" {
|
|
||||||
fmt.Printf("This account uses 2 factor authentication you will receive a verification code via SMS.\n")
|
|
||||||
fmt.Printf("Enter verification code> ")
|
|
||||||
authCode := config.ReadLine()
|
|
||||||
|
|
||||||
authCode = strings.Replace(authCode, "-", "", -1) // remove any "-" contained in the code so we have a 6 digit number
|
|
||||||
opts.ExtraHeaders = make(map[string]string)
|
|
||||||
opts.ExtraHeaders["X-Jottacloud-Otp"] = authCode
|
|
||||||
resp, err = srv.CallJSON(ctx, &opts, nil, &jsonToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token.AccessToken = jsonToken.AccessToken
|
token.AccessToken = jsonToken.AccessToken
|
||||||
|
@ -471,29 +448,6 @@ func (f *Fs) filePath(file string) string {
|
||||||
return urlPathEscape(f.filePathRaw(file))
|
return urlPathEscape(f.filePathRaw(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jottacloud requires the grant_type 'refresh_token' string
|
|
||||||
// to be uppercase and throws a 400 Bad Request if we use the
|
|
||||||
// lower case used by the oauth2 module
|
|
||||||
//
|
|
||||||
// This filter catches all refresh requests, reads the body,
|
|
||||||
// changes the case and then sends it on
|
|
||||||
func grantTypeFilter(req *http.Request) {
|
|
||||||
if tokenURL == req.URL.String() {
|
|
||||||
// read the entire body
|
|
||||||
refreshBody, err := ioutil.ReadAll(req.Body)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = req.Body.Close()
|
|
||||||
|
|
||||||
// make the refresh token upper case
|
|
||||||
refreshBody = []byte(strings.Replace(string(refreshBody), "grant_type=refresh_token", "grant_type=REFRESH_TOKEN", 1))
|
|
||||||
|
|
||||||
// set the new ReadCloser (with a dummy Close())
|
|
||||||
req.Body = ioutil.NopCloser(bytes.NewReader(refreshBody))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFs constructs an Fs from the path, container:path
|
// NewFs constructs an Fs from the path, container:path
|
||||||
func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) {
|
func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) {
|
||||||
ctx := context.TODO()
|
ctx := context.TODO()
|
||||||
|
@ -504,30 +458,23 @@ func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
var version string
|
||||||
|
if version, ok = m.Get("configVersion"); ok {
|
||||||
|
ver, err := strconv.Atoi(version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("Failed to parse config version")
|
||||||
|
}
|
||||||
|
ok = ver == configVersion
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("Outdated config - please reconfigure this backend")
|
||||||
|
}
|
||||||
|
|
||||||
rootIsDir := strings.HasSuffix(root, "/")
|
rootIsDir := strings.HasSuffix(root, "/")
|
||||||
root = parsePath(root)
|
root = parsePath(root)
|
||||||
|
|
||||||
clientID, ok := m.Get(configClientID)
|
|
||||||
if !ok {
|
|
||||||
clientID = rcloneClientID
|
|
||||||
}
|
|
||||||
clientSecret, ok := m.Get(configClientSecret)
|
|
||||||
if !ok {
|
|
||||||
clientSecret = rcloneEncryptedClientSecret
|
|
||||||
}
|
|
||||||
oauthConfig.ClientID = clientID
|
|
||||||
oauthConfig.ClientSecret = obscure.MustReveal(clientSecret)
|
|
||||||
|
|
||||||
// the oauth client for the api servers needs
|
|
||||||
// a filter to fix the grant_type issues (see above)
|
|
||||||
baseClient := fshttp.NewClient(fs.Config)
|
baseClient := fshttp.NewClient(fs.Config)
|
||||||
if do, ok := baseClient.Transport.(interface {
|
|
||||||
SetRequestFilter(f func(req *http.Request))
|
|
||||||
}); ok {
|
|
||||||
do.SetRequestFilter(grantTypeFilter)
|
|
||||||
} else {
|
|
||||||
fs.Debugf(name+":", "Couldn't add request filter - uploads will fail")
|
|
||||||
}
|
|
||||||
oAuthClient, ts, err := oauthutil.NewClientWithBaseClient(name, m, oauthConfig, baseClient)
|
oAuthClient, ts, err := oauthutil.NewClientWithBaseClient(name, m, oauthConfig, baseClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "Failed to configure Jottacloud oauth client")
|
return nil, errors.Wrap(err, "Failed to configure Jottacloud oauth client")
|
||||||
|
|
Loading…
Reference in a new issue