WIP
parent
ad2a3bc0d2
commit
f1e7ae30c3
120
server/auth.go
120
server/auth.go
|
@ -4,6 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"github.com/influxdata/chronograf"
|
"github.com/influxdata/chronograf"
|
||||||
)
|
)
|
||||||
|
@ -74,3 +77,120 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor
|
||||||
return
|
return
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login returns a handler that redirects to the providers OAuth login.
|
||||||
|
// Uses JWT with a random string as the state validation method.
|
||||||
|
// JWTs are used because they can be validated without storing state.
|
||||||
|
func Login(provider OAuth2Provider, auth chronograf.Authenticator, logger chronograf.Logger) http.HandlerFunc {
|
||||||
|
conf := provider.Config()
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// We are creating a token with an encoded random string to prevent CSRF attacks
|
||||||
|
// This token will be validated during the OAuth callback.
|
||||||
|
// We'll give our users 10 minutes from this point to type in their github password.
|
||||||
|
// If the callback is not received within 10 minutes, then authorization will fail.
|
||||||
|
csrf := randomString(32) // 32 is not important... just long
|
||||||
|
state, err := auth.Token(r.Context(), chronograf.Principal(csrf), 10*time.Minute)
|
||||||
|
// This is likely an internal server error
|
||||||
|
if err != nil {
|
||||||
|
logger.
|
||||||
|
WithField("component", "auth").
|
||||||
|
WithField("remote_addr", r.RemoteAddr).
|
||||||
|
WithField("method", r.Method).
|
||||||
|
WithField("url", r.URL).
|
||||||
|
Error("Internal authentication error: ", err.Error())
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||||
|
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handler will expire our authentication cookie and redirect to the successURL
|
||||||
|
func Logout(cookie Cookie, successURL string, now func() time.Time) http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deleteCookie := http.Cookie{
|
||||||
|
Name: cookie.Name,
|
||||||
|
Value: "none",
|
||||||
|
Expires: now().UTC().Add(-1 * time.Hour),
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &deleteCookie)
|
||||||
|
http.Redirect(w, r, successURL, http.StatusTemporaryRedirect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallbackOpts are the options for the Callback handler
|
||||||
|
type CallbackOpts struct {
|
||||||
|
Provider OAuth2Provider
|
||||||
|
Auth chronograf.Authenticator
|
||||||
|
Cookie Cookie
|
||||||
|
Logger chronograf.Logger
|
||||||
|
SuccessURL string // SuccessURL is redirect location after successful authorization
|
||||||
|
FailureURL string // FailureURL is redirect location after authorization failure
|
||||||
|
Now func() time.Time // Now returns the current time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback is used by OAuth2 provider after authorization is granted. If
|
||||||
|
// granted, Callback will set a cookie with a month-long expiration. The
|
||||||
|
// value of the cookie is a JWT because the JWT can be validated without
|
||||||
|
// the need for saving state. The JWT contains the principal's identifier (e.g.
|
||||||
|
// email address).
|
||||||
|
func Callback(opts CallbackOpts) http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := opts.Logger.
|
||||||
|
WithField("component", "auth").
|
||||||
|
WithField("remote_addr", r.RemoteAddr).
|
||||||
|
WithField("method", r.Method).
|
||||||
|
WithField("url", r.URL)
|
||||||
|
|
||||||
|
state := r.FormValue("state")
|
||||||
|
// Check if the OAuth state token is valid to prevent CSRF
|
||||||
|
_, err := opts.Auth.Authenticate(r.Context(), state)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Invalid OAuth state received: ", err.Error())
|
||||||
|
http.Redirect(w, r, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange the code back with the provider to the the token
|
||||||
|
conf := opts.Provider.Config()
|
||||||
|
code := r.FormValue("code")
|
||||||
|
token, err := conf.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to exchange code for token ", err.Error())
|
||||||
|
http.Redirect(w, r, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using the token get the principal identifier from the provider
|
||||||
|
oauthClient := conf.Client(r.Context(), token)
|
||||||
|
id, err := opts.Provider.PrincipalID(oauthClient)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to get principal identifier ", err.Error())
|
||||||
|
http.Redirect(w, r, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We create an auth token that will be used by all other endpoints to validate the principal has a claim
|
||||||
|
authToken, err := opts.Auth.Token(r.Context(), chronograf.Principal(id), opts.Cookie.Duration)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to create cookie auth token ", err.Error())
|
||||||
|
http.Redirect(w, r, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expireCookie := opts.Now().UTC().Add(opts.Cookie.Duration)
|
||||||
|
cookie := http.Cookie{
|
||||||
|
Name: opts.Cookie.Name,
|
||||||
|
Value: authToken,
|
||||||
|
Expires: expireCookie,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
log.Info("User ", id, " is authenticated")
|
||||||
|
http.SetCookie(w, &cookie)
|
||||||
|
http.Redirect(w, r, opts.SuccessURL, http.StatusTemporaryRedirect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
142
server/github.go
142
server/github.go
|
@ -35,29 +35,31 @@ func NewCookie() Cookie {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ OAuth2Provider = &Github{}
|
||||||
|
|
||||||
// Github provides OAuth Login and Callback server. Callback will set
|
// Github provides OAuth Login and Callback server. Callback will set
|
||||||
// an authentication cookie. This cookie's value is a JWT containing
|
// an authentication cookie. This cookie's value is a JWT containing
|
||||||
// the user's primary Github email address.
|
// the user's primary Github email address.
|
||||||
type Github struct {
|
type Github struct {
|
||||||
Cookie Cookie
|
|
||||||
Auth chronograf.Authenticator
|
Auth chronograf.Authenticator
|
||||||
ClientID string
|
ClientID string
|
||||||
ClientSecret string
|
ClientSecret string
|
||||||
SuccessURL string // SuccessURL is redirect location after successful authorization
|
|
||||||
FailureURL string // FailureURL is redirect location after authorization failure
|
|
||||||
Orgs []string // Optional github organization checking
|
Orgs []string // Optional github organization checking
|
||||||
Now func() time.Time
|
|
||||||
Logger chronograf.Logger
|
Logger chronograf.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ID returns the github application client id
|
||||||
func (g *Github) ID() string {
|
func (g *Github) ID() string {
|
||||||
return g.ClientID
|
return g.ClientID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secret returns the github application client secret
|
||||||
func (g *Github) Secret() string {
|
func (g *Github) Secret() string {
|
||||||
return g.ClientSecret
|
return g.ClientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scopes for github is only the email addres and possible organizations if
|
||||||
|
// we are filtering by organizations.
|
||||||
func (g *Github) Scopes() []string {
|
func (g *Github) Scopes() []string {
|
||||||
scopes := []string{"user:email"}
|
scopes := []string{"user:email"}
|
||||||
if len(g.Orgs) > 0 {
|
if len(g.Orgs) > 0 {
|
||||||
|
@ -66,12 +68,8 @@ func (g *Github) Scopes() []string {
|
||||||
return scopes
|
return scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Github) Authenticator() chronograf.Authenticator {
|
// NewGithub constructs a Github with default scopes.
|
||||||
return g.Auth
|
func NewGithub(clientID, clientSecret string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github {
|
||||||
}
|
|
||||||
|
|
||||||
// NewGithub constructs a Github with default cookie behavior and scopes.
|
|
||||||
func NewGithub(clientID, clientSecret, successURL, failureURL string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github {
|
|
||||||
scopes := []string{"user:email"}
|
scopes := []string{"user:email"}
|
||||||
if len(orgs) > 0 {
|
if len(orgs) > 0 {
|
||||||
scopes = append(scopes, "read:org")
|
scopes = append(scopes, "read:org")
|
||||||
|
@ -79,17 +77,14 @@ func NewGithub(clientID, clientSecret, successURL, failureURL string, orgs []str
|
||||||
return Github{
|
return Github{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
Cookie: NewCookie(),
|
|
||||||
Orgs: orgs,
|
Orgs: orgs,
|
||||||
SuccessURL: successURL,
|
|
||||||
FailureURL: failureURL,
|
|
||||||
Auth: auth,
|
Auth: auth,
|
||||||
Now: time.Now,
|
|
||||||
Logger: log,
|
Logger: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Github) config() *oauth2.Config {
|
// Config is the Github OAuth2 exchange information and endpoints
|
||||||
|
func (g *Github) Config() *oauth2.Config {
|
||||||
return &oauth2.Config{
|
return &oauth2.Config{
|
||||||
ClientID: g.ID(),
|
ClientID: g.ID(),
|
||||||
ClientSecret: g.Secret(),
|
ClientSecret: g.Secret(),
|
||||||
|
@ -98,125 +93,28 @@ func (g *Github) config() *oauth2.Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login returns a handler that redirects to Github's OAuth login.
|
// PrincipalID returns the github email address of the user.
|
||||||
// Uses JWT with a random string as the state validation method.
|
func (g *Github) PrincipalID(provider *http.Client) (string, error) {
|
||||||
// JWTs are used because they can be validated without storing
|
client := github.NewClient(provider)
|
||||||
// state.
|
|
||||||
func (g *Github) Login() http.HandlerFunc {
|
|
||||||
conf := g.config()
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// We are creating a token with an encoded random string to prevent CSRF attacks
|
|
||||||
// This token will be validated during the OAuth callback.
|
|
||||||
// We'll give our users 10 minutes from this point to type in their github password.
|
|
||||||
// If the callback is not received within 10 minutes, then authorization will fail.
|
|
||||||
csrf := randomString(32) // 32 is not important... just long
|
|
||||||
state, err := g.Authenticator().Token(r.Context(), chronograf.Principal(csrf), 10*time.Minute)
|
|
||||||
// This is likely an internal server error
|
|
||||||
if err != nil {
|
|
||||||
g.Logger.
|
|
||||||
WithField("component", "auth").
|
|
||||||
WithField("remote_addr", r.RemoteAddr).
|
|
||||||
WithField("method", r.Method).
|
|
||||||
WithField("url", r.URL).
|
|
||||||
Error("Internal authentication error: ", err.Error())
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
|
||||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout will expire our authentication cookie and redirect to the SuccessURL
|
|
||||||
func (g *Github) Logout() http.HandlerFunc {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
deleteCookie := http.Cookie{
|
|
||||||
Name: g.Cookie.Name,
|
|
||||||
Value: "none",
|
|
||||||
Expires: g.Now().UTC().Add(-1 * time.Hour),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &deleteCookie)
|
|
||||||
http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback used by github callback after authorization is granted. If
|
|
||||||
// granted, Callback will set a cookie with a month-long expiration. The
|
|
||||||
// value of the cookie is a JWT because the JWT can be validated without
|
|
||||||
// the need for saving state. The JWT contains the Github user's primary
|
|
||||||
// email address.
|
|
||||||
func (g *Github) Callback() http.HandlerFunc {
|
|
||||||
conf := g.config()
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log := g.Logger.
|
|
||||||
WithField("component", "auth").
|
|
||||||
WithField("remote_addr", r.RemoteAddr).
|
|
||||||
WithField("method", r.Method).
|
|
||||||
WithField("url", r.URL)
|
|
||||||
|
|
||||||
state := r.FormValue("state")
|
|
||||||
// Check if the OAuth state token is valid to prevent CSRF
|
|
||||||
_, err := g.Authenticator().Authenticate(r.Context(), state)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Invalid OAuth state received: ", err.Error())
|
|
||||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
code := r.FormValue("code")
|
|
||||||
token, err := conf.Exchange(r.Context(), code)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to exchange code for token ", err.Error())
|
|
||||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthClient := conf.Client(r.Context(), token)
|
|
||||||
client := github.NewClient(oauthClient)
|
|
||||||
// If we need to restrict to a set of organizations, we first get the org
|
// If we need to restrict to a set of organizations, we first get the org
|
||||||
// and filter.
|
// and filter.
|
||||||
if len(g.Orgs) > 0 {
|
if len(g.Orgs) > 0 {
|
||||||
orgs, err := getOrganizations(client, log)
|
orgs, err := getOrganizations(client, g.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
return "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Not a member, so, deny permission
|
// Not a member, so, deny permission
|
||||||
if ok := isMember(g.Orgs, orgs); !ok {
|
if ok := isMember(g.Orgs, orgs); !ok {
|
||||||
log.Error("Not a member of required github organization")
|
g.Logger.Error("Not a member of required github organization")
|
||||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
return "", err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
email, err := getPrimaryEmail(client, log)
|
email, err := getPrimaryEmail(client, g.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
return "", nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return email, nil
|
||||||
// We create an auth token that will be used by all other endpoints to validate the principal has a claim
|
|
||||||
authToken, err := g.Authenticator().Token(r.Context(), chronograf.Principal(email), g.Cookie.Duration)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Unable to create cookie auth token ", err.Error())
|
|
||||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expireCookie := g.Now().UTC().Add(g.Cookie.Duration)
|
|
||||||
cookie := http.Cookie{
|
|
||||||
Name: g.Cookie.Name,
|
|
||||||
Value: authToken,
|
|
||||||
Expires: expireCookie,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
}
|
|
||||||
log.Info("User ", email, " is authenticated")
|
|
||||||
http.SetCookie(w, &cookie)
|
|
||||||
http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomString(length int) string {
|
func randomString(length int) string {
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
type Google struct {
|
|
||||||
OAuth2Provider
|
|
||||||
Domains []string // Optional google email domain checking
|
|
||||||
}
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
type Google struct {
|
||||||
|
OAuth2Provider
|
||||||
|
Domains []string // Optional google email domain checking
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
client := conf.Client(oauth2.NoContext, tok)
|
||||||
|
email, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer email.Body.Close()
|
||||||
|
data, _ := ioutil.ReadAll(email.Body)
|
||||||
|
log.Println("Email body: ", string(data))
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
*/
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/NYTimes/gziphandler"
|
"github.com/NYTimes/gziphandler"
|
||||||
"github.com/bouk/httprouter"
|
"github.com/bouk/httprouter"
|
||||||
|
@ -57,6 +58,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
||||||
/* API */
|
/* API */
|
||||||
// Root Routes returns all top-level routes in the API
|
// Root Routes returns all top-level routes in the API
|
||||||
router.GET("/chronograf/v1/", AllRoutes(opts.Logger))
|
router.GET("/chronograf/v1/", AllRoutes(opts.Logger))
|
||||||
|
router.GET("/chronograf/v1", AllRoutes(opts.Logger))
|
||||||
|
|
||||||
// Sources
|
// Sources
|
||||||
router.GET("/chronograf/v1/sources", service.Sources)
|
router.GET("/chronograf/v1/sources", service.Sources)
|
||||||
|
@ -133,18 +135,23 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
||||||
func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
|
func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
|
||||||
auth := jwt.NewJWT(opts.TokenSecret)
|
auth := jwt.NewJWT(opts.TokenSecret)
|
||||||
|
|
||||||
successURL := "/"
|
|
||||||
failureURL := "/login"
|
|
||||||
gh := NewGithub(
|
gh := NewGithub(
|
||||||
opts.GithubClientID,
|
opts.GithubClientID,
|
||||||
opts.GithubClientSecret,
|
opts.GithubClientSecret,
|
||||||
successURL,
|
|
||||||
failureURL,
|
|
||||||
opts.GithubOrgs,
|
opts.GithubOrgs,
|
||||||
&auth,
|
&auth,
|
||||||
opts.Logger,
|
opts.Logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
callback := CallbackOpts{
|
||||||
|
Provider: &gh,
|
||||||
|
Auth: &auth,
|
||||||
|
Cookie: NewCookie(),
|
||||||
|
Logger: opts.Logger,
|
||||||
|
SuccessURL: "/",
|
||||||
|
FailureURL: "/login",
|
||||||
|
Now: time.Now,
|
||||||
|
}
|
||||||
router.GET("/oauth/github", gh.Login())
|
router.GET("/oauth/github", gh.Login())
|
||||||
router.GET("/oauth/logout", gh.Logout())
|
router.GET("/oauth/logout", gh.Logout())
|
||||||
router.GET("/oauth/github/callback", gh.Callback())
|
router.GET("/oauth/github/callback", gh.Callback())
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import "github.com/influxdata/chronograf"
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
// OAuth2Provider are the common parameters for all providers (RFC 6749)
|
// OAuth2Provider are the common parameters for all providers (RFC 6749)
|
||||||
type OAuth2Provider interface {
|
type OAuth2Provider interface {
|
||||||
|
@ -10,6 +14,8 @@ type OAuth2Provider interface {
|
||||||
Secret() string
|
Secret() string
|
||||||
// Scopes is used by the authorization server to "scope" responses (Section 3.3)
|
// Scopes is used by the authorization server to "scope" responses (Section 3.3)
|
||||||
Scopes() []string
|
Scopes() []string
|
||||||
// Authenticator generates and validates tokens
|
// Config is the OAuth2 configuration settings for this provider
|
||||||
Authenticator() chronograf.Authenticator
|
Config() *oauth2.Config
|
||||||
|
// PrincipalID with fetch the identifier to be associated with the principal.
|
||||||
|
PrincipalID(provider *http.Client) (string, error)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue