lib/oauthutil: refactor web server and allow an auth callback

This commit is contained in:
Nick Craig-Wood 2019-08-30 11:52:03 +01:00
parent 25a0e7e8aa
commit f29e5b6e7d

View file

@ -2,13 +2,12 @@ package oauthutil
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"net" "net"
"net/http" "net/http"
"strings" "net/url"
"sync" "sync"
"time" "time"
@ -17,6 +16,7 @@ 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/fshttp" "github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/lib/random"
"github.com/skratchdot/open-golang/open" "github.com/skratchdot/open-golang/open"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -46,8 +46,8 @@ const (
// redirects to the local webserver // redirects to the local webserver
RedirectPublicSecureURL = "https://oauth.rclone.org/" RedirectPublicSecureURL = "https://oauth.rclone.org/"
// AuthResponse is a template to handle the redirect URL for oauth requests // AuthResponseTemplate is a template to handle the redirect URL for oauth requests
AuthResponse = `<!DOCTYPE html> AuthResponseTemplate = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -58,17 +58,12 @@ const (
<hr> <hr>
<pre style="width: 750px; white-space: pre-wrap;"> <pre style="width: 750px; white-space: pre-wrap;">
{{ if eq .OK false }} {{ if eq .OK false }}
Error: {{ .AuthError.Name }}<br> Error: {{ .Name }}<br>
{{ if .AuthError.Description }}Description: {{ .AuthError.Description }}<br>{{ end }} {{ if .Description }}Description: {{ .Description }}<br>{{ end }}
{{ if .AuthError.Code }}Code: {{ .AuthError.Code }}<br>{{ end }} {{ if .Code }}Code: {{ .Code }}<br>{{ end }}
{{ if .AuthError.HelpURL }}Look here for help: <a href="{{ .AuthError.HelpURL }}">{{ .AuthError.HelpURL }}</a><br>{{ end }} {{ if .HelpURL }}Look here for help: <a href="{{ .HelpURL }}">{{ .HelpURL }}</a><br>{{ end }}
{{ else }} {{ else }}
{{ if .Code }}
Please copy this code into rclone:
{{ .Code }}
{{ else }}
All done. Please go back to rclone. All done. Please go back to rclone.
{{ end }}
{{ end }} {{ end }}
</pre> </pre>
</body> </body>
@ -340,26 +335,54 @@ func NewClient(name string, m configmap.Mapper, oauthConfig *oauth2.Config) (*ht
return NewClientWithBaseClient(name, m, oauthConfig, fshttp.NewClient(fs.Config)) return NewClientWithBaseClient(name, m, oauthConfig, fshttp.NewClient(fs.Config))
} }
// AuthResult is returned from the web server after authorization
// success or failure
type AuthResult struct {
OK bool // Failure or Success?
Name string
Description string
Code string
HelpURL string
Form url.Values // the complete contents of the form
Err error // any underlying error to report
}
// Error satisfies the error interface so AuthResult can be used as an error
func (ar *AuthResult) Error() string {
status := "Error"
if ar.OK {
status = "OK"
}
return fmt.Sprintf("%s: %s\nCode: %q\nDescription: %s\nHelp: %s",
status, ar.Name, ar.Code, ar.Description, ar.HelpURL)
}
// Config does the initial creation of the token // Config does the initial creation of the token
// //
// It may run an internal webserver to receive the results // It may run an internal webserver to receive the results
func Config(id, name string, m configmap.Mapper, config *oauth2.Config, opts ...oauth2.AuthCodeOption) error { func Config(id, name string, m configmap.Mapper, config *oauth2.Config, opts ...oauth2.AuthCodeOption) error {
return doConfig(id, name, m, nil, config, true, opts) return doConfig(id, name, m, config, true, nil, opts)
}
// CheckAuthFn is called when a good Auth has been received
type CheckAuthFn func(*oauth2.Config, *AuthResult) error
// ConfigWithCallback does the initial creation of the token
//
// It may run an internal webserver to receive the results
//
// When the AuthResult is known the checkAuth function is called if set
func ConfigWithCallback(id, name string, m configmap.Mapper, config *oauth2.Config, checkAuth CheckAuthFn, opts ...oauth2.AuthCodeOption) error {
return doConfig(id, name, m, config, true, checkAuth, opts)
} }
// ConfigNoOffline does the same as Config but does not pass the // ConfigNoOffline does the same as Config but does not pass the
// "access_type=offline" parameter. // "access_type=offline" parameter.
func ConfigNoOffline(id, name string, m configmap.Mapper, config *oauth2.Config, opts ...oauth2.AuthCodeOption) error { func ConfigNoOffline(id, name string, m configmap.Mapper, config *oauth2.Config, opts ...oauth2.AuthCodeOption) error {
return doConfig(id, name, m, nil, config, false, opts) return doConfig(id, name, m, config, false, nil, opts)
} }
// ConfigErrorCheck does the same as Config, but allows the backend to pass a error handling function func doConfig(id, name string, m configmap.Mapper, oauthConfig *oauth2.Config, offline bool, checkAuth CheckAuthFn, opts []oauth2.AuthCodeOption) error {
// This function gets called with the request made to rclone as a parameter if no code was found
func ConfigErrorCheck(id, name string, m configmap.Mapper, errorHandler func(*http.Request) AuthError, config *oauth2.Config, opts ...oauth2.AuthCodeOption) error {
return doConfig(id, name, m, errorHandler, config, true, opts)
}
func doConfig(id, name string, m configmap.Mapper, errorHandler func(*http.Request) AuthError, oauthConfig *oauth2.Config, offline bool, opts []oauth2.AuthCodeOption) error {
oauthConfig, changed := overrideCredentials(name, m, oauthConfig) oauthConfig, changed := overrideCredentials(name, m, oauthConfig)
authorizeOnlyValue, ok := m.Get(config.ConfigAuthorize) authorizeOnlyValue, ok := m.Get(config.ConfigAuthorize)
authorizeOnly := ok && authorizeOnlyValue != "" // set if being run by "rclone authorize" authorizeOnly := ok && authorizeOnlyValue != "" // set if being run by "rclone authorize"
@ -384,7 +407,18 @@ func doConfig(id, name string, m configmap.Mapper, errorHandler func(*http.Reque
// Detect whether we should use internal web server // Detect whether we should use internal web server
useWebServer := false useWebServer := false
switch oauthConfig.RedirectURL { switch oauthConfig.RedirectURL {
case RedirectURL, RedirectPublicURL, RedirectLocalhostURL, RedirectPublicSecureURL: case TitleBarRedirectURL:
useWebServer = authorizeOnly
if !authorizeOnly {
useWebServer = isLocal()
}
if useWebServer {
// copy the config and set to use the internal webserver
configCopy := *oauthConfig
oauthConfig = &configCopy
oauthConfig.RedirectURL = RedirectURL
}
default:
if changed { if changed {
fmt.Printf("Make sure your Redirect URL is set to %q in your custom config.\n", oauthConfig.RedirectURL) fmt.Printf("Make sure your Redirect URL is set to %q in your custom config.\n", oauthConfig.RedirectURL)
} }
@ -401,11 +435,7 @@ func doConfig(id, name string, m configmap.Mapper, errorHandler func(*http.Reque
fmt.Printf("\trclone authorize %q\n", id) fmt.Printf("\trclone authorize %q\n", id)
} }
fmt.Println("Then paste the result below:") fmt.Println("Then paste the result below:")
code := "" code := config.ReadNonEmptyLine("result> ")
for code == "" {
fmt.Printf("result> ")
code = strings.TrimSpace(config.ReadLine())
}
token := &oauth2.Token{} token := &oauth2.Token{}
err := json.Unmarshal([]byte(code), token) err := json.Unmarshal([]byte(code), token)
if err != nil { if err != nil {
@ -413,41 +443,24 @@ func doConfig(id, name string, m configmap.Mapper, errorHandler func(*http.Reque
} }
return PutToken(name, m, token, true) return PutToken(name, m, token, true)
} }
case TitleBarRedirectURL:
useWebServer = authorizeOnly
if !authorizeOnly {
useWebServer = isLocal()
}
if useWebServer {
// copy the config and set to use the internal webserver
configCopy := *oauthConfig
oauthConfig = &configCopy
oauthConfig.RedirectURL = RedirectURL
}
} }
// Make random state // Make random state
stateBytes := make([]byte, 16) state, err := random.Password(128)
_, err := rand.Read(stateBytes)
if err != nil { if err != nil {
return err return err
} }
state := fmt.Sprintf("%x", stateBytes)
// Generate oauth URL
if offline { if offline {
opts = append(opts, oauth2.AccessTypeOffline) opts = append(opts, oauth2.AccessTypeOffline)
} }
authURL := oauthConfig.AuthCodeURL(state, opts...) authURL := oauthConfig.AuthCodeURL(state, opts...)
// Prepare webserver // Prepare webserver if needed
server := authServer{ var server *authServer
state: state,
bindAddress: bindAddress,
authURL: authURL,
errorHandler: errorHandler,
}
if useWebServer { if useWebServer {
server.code = make(chan string, 1) server = newAuthServer(bindAddress, state, authURL)
server.err = make(chan error, 1)
err := server.Init() err := server.Init()
if err != nil { if err != nil {
return errors.Wrap(err, "failed to start auth webserver") return errors.Wrap(err, "failed to start auth webserver")
@ -457,36 +470,39 @@ func doConfig(id, name string, m configmap.Mapper, errorHandler func(*http.Reque
authURL = "http://" + bindAddress + "/auth?state=" + state authURL = "http://" + bindAddress + "/auth?state=" + state
} }
// Generate a URL for the user to visit for authorization. // Open the URL for the user to visit
_ = open.Start(authURL) _ = open.Start(authURL)
fmt.Printf("If your browser doesn't open automatically go to the following link: %s\n", authURL) fmt.Printf("If your browser doesn't open automatically go to the following link: %s\n", authURL)
fmt.Printf("Log in and authorize rclone for access\n") fmt.Printf("Log in and authorize rclone for access\n")
var authCode string // Read the code via the webserver or manually
var auth *AuthResult
if useWebServer { if useWebServer {
// Read the code, and exchange it for a token.
fmt.Printf("Waiting for code...\n") fmt.Printf("Waiting for code...\n")
authCode = <-server.code auth = <-server.result
authError := <-server.err if !auth.OK || auth.Code == "" {
if authCode != "" { return auth
fmt.Printf("Got code\n") }
} else { fmt.Printf("Got code\n")
if authError != nil { if checkAuth != nil {
return authError err = checkAuth(oauthConfig, auth)
if err != nil {
return err
} }
return errors.New("failed to get code")
} }
} else { } else {
// Read the code, and exchange it for a token. auth = &AuthResult{
fmt.Printf("Enter verification code> ") Code: config.ReadNonEmptyLine("Enter verification code> "),
authCode = config.ReadLine() }
} }
token, err := oauthConfig.Exchange(oauth2.NoContext, authCode)
// Exchange the code for a token
token, err := oauthConfig.Exchange(oauth2.NoContext, auth.Code)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to get token") return errors.Wrap(err, "failed to get token")
} }
// Print code if we do automatic retrieval // Print code if we are doing a manual auth
if authorizeOnly { if authorizeOnly {
result, err := json.Marshal(token) result, err := json.Marshal(token)
if err != nil { if err != nil {
@ -499,29 +515,75 @@ func doConfig(id, name string, m configmap.Mapper, errorHandler func(*http.Reque
// Local web server for collecting auth // Local web server for collecting auth
type authServer struct { type authServer struct {
state string state string
listener net.Listener listener net.Listener
bindAddress string bindAddress string
code chan string authURL string
err chan error server *http.Server
authURL string result chan *AuthResult
server *http.Server
errorHandler func(*http.Request) AuthError
} }
// AuthError gets returned by the backend's errorHandler function // newAuthServer makes the webserver for collecting auth
type AuthError struct { func newAuthServer(bindAddress, state, authURL string) *authServer {
Name string return &authServer{
Description string state: state,
Code string bindAddress: bindAddress,
HelpURL string authURL: authURL, // http://host/auth redirects to here
result: make(chan *AuthResult, 1),
}
} }
// AuthResponseData can fill the AuthResponse template // Receive the auth request
type AuthResponseData struct { func (s *authServer) handleAuth(w http.ResponseWriter, req *http.Request) {
OK bool // Failure or Success? fs.Debugf(nil, "Received %s request on auth server to %q", req.Method, req.URL.Path)
Code string // code to paste into rclone config
AuthError // Reply with the response to the user and to the channel
reply := func(status int, res *AuthResult) {
w.WriteHeader(status)
w.Header().Set("Content-Type", "text/html")
var t = template.Must(template.New("authResponse").Parse(AuthResponseTemplate))
if err := t.Execute(w, res); err != nil {
fs.Debugf(nil, "Could not execute template for web response.")
}
s.result <- res
}
// Parse the form parameters and save them
err := req.ParseForm()
if err != nil {
reply(http.StatusBadRequest, &AuthResult{
Name: "Parse form error",
Description: err.Error(),
})
return
}
// get code, error if empty
code := req.Form.Get("code")
if code == "" {
reply(http.StatusBadRequest, &AuthResult{
Name: "Auth Error",
Description: "No code returned by remote server",
})
return
}
// check state
state := req.Form.Get("state")
if state != s.state {
reply(http.StatusBadRequest, &AuthResult{
Name: "Auth state doesn't match",
Description: fmt.Sprintf("Expecting %q got %q", s.state, state),
})
return
}
// code OK
reply(http.StatusOK, &AuthResult{
OK: true,
Code: code,
Form: req.Form,
})
} }
// Init gets the internal web server ready to receive config details // Init gets the internal web server ready to receive config details
@ -533,67 +595,22 @@ func (s *authServer) Init() error {
Handler: mux, Handler: mux,
} }
s.server.SetKeepAlivesEnabled(false) s.server.SetKeepAlivesEnabled(false)
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, req *http.Request) { mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, req *http.Request) {
http.Error(w, "", 404) http.Error(w, "", http.StatusNotFound)
return return
}) })
mux.HandleFunc("/auth", func(w http.ResponseWriter, req *http.Request) { mux.HandleFunc("/auth", func(w http.ResponseWriter, req *http.Request) {
state := req.FormValue("state") state := req.FormValue("state")
if state != s.state { if state != s.state {
fs.Debugf(nil, "State did not match: want %q got %q", s.state, state) fs.Debugf(nil, "State did not match: want %q got %q", s.state, state)
http.Error(w, "State did not match - please try again", 403) http.Error(w, "State did not match - please try again", http.StatusForbidden)
return return
} }
http.Redirect(w, req, s.authURL, http.StatusTemporaryRedirect) http.Redirect(w, req, s.authURL, http.StatusTemporaryRedirect)
return return
}) })
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { mux.HandleFunc("/", s.handleAuth)
w.Header().Set("Content-Type", "text/html")
fs.Debugf(nil, "Received request on auth server")
code := req.FormValue("code")
var err error
var t = template.Must(template.New("authResponse").Parse(AuthResponse))
resp := AuthResponseData{AuthError: AuthError{}}
if code != "" {
state := req.FormValue("state")
if state != s.state {
fs.Debugf(nil, "State did not match: want %q got %q", s.state, state)
resp.OK = false
resp.AuthError = AuthError{
Name: "Auth State doesn't match",
}
} else {
fs.Debugf(nil, "Successfully got code")
resp.OK = true
if s.code == nil {
resp.Code = code
}
}
} else {
fs.Debugf(nil, "No code found on request")
var authError AuthError
if s.errorHandler == nil {
authError = AuthError{
Name: "Auth Error",
Description: "No code found returned by remote server.",
}
} else {
authError = s.errorHandler(req)
}
err = fmt.Errorf("Error: %s\nCode: %s\nDescription: %s\nHelp: %s",
authError.Name, authError.Code, authError.Description, authError.HelpURL)
resp.OK = false
resp.AuthError = authError
w.WriteHeader(500)
}
if err := t.Execute(w, resp); err != nil {
fs.Debugf(nil, "Could not execute template for web response.")
}
if s.code != nil {
s.code <- code
s.err <- err
}
})
var err error var err error
s.listener, err = net.Listen("tcp", s.bindAddress) s.listener, err = net.Listen("tcp", s.bindAddress)
@ -612,10 +629,7 @@ func (s *authServer) Serve() {
// Stop the auth server by closing its socket // Stop the auth server by closing its socket
func (s *authServer) Stop() { func (s *authServer) Stop() {
fs.Debugf(nil, "Closing auth server") fs.Debugf(nil, "Closing auth server")
if s.code != nil { close(s.result)
close(s.code)
s.code = nil
}
_ = s.listener.Close() _ = s.listener.Close()
// close the server // close the server