1 | 1 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,76 @@ |
0 |
+package redirector |
|
1 |
+ |
|
2 |
+import ( |
|
3 |
+ "net/http" |
|
4 |
+ "net/url" |
|
5 |
+ "strings" |
|
6 |
+ |
|
7 |
+ oauthhandlers "github.com/openshift/origin/pkg/auth/oauth/handlers" |
|
8 |
+) |
|
9 |
+ |
|
10 |
+const ( |
|
11 |
+ // URLToken in the query of the redirectURL gets replaced with the original request URL, escaped as a query parameter. |
|
12 |
+ // Example use: https://www.example.com/login?then=${url} |
|
13 |
+ URLToken = "${url}" |
|
14 |
+ |
|
15 |
+ // QueryToken in the query of the redirectURL gets replaced with the original request URL, unescaped. |
|
16 |
+ // Example use: https://www.example.com/sso/oauth/authorize?${query} |
|
17 |
+ QueryToken = "${query}" |
|
18 |
+) |
|
19 |
+ |
|
20 |
+// NewRedirector returns an oauthhandlers.AuthenticationRedirector that redirects to the specified redirectURL. |
|
21 |
+// Request URLs missing scheme/host, or with relative paths are resolved relative to the baseRequestURL, if specified. |
|
22 |
+// The following tokens are replaceable in the query of the redirectURL: |
|
23 |
+// ${url} is replaced with the current request URL, escaped as a query parameter. Example: https://www.example.com/login?then=${url} |
|
24 |
+// ${query} is replaced with the current request query, unescaped. Example: https://www.example.com/sso/oauth/authorize?${query} |
|
25 |
+func NewRedirector(baseRequestURL *url.URL, redirectURL string) oauthhandlers.AuthenticationRedirector { |
|
26 |
+ return &redirector{BaseRequestURL: baseRequestURL, RedirectURL: redirectURL} |
|
27 |
+} |
|
28 |
+ |
|
29 |
+// NewChallenger returns an oauthhandlers.AuthenticationChallenger that returns a Location header to the specified redirectURL. |
|
30 |
+// Request URLs missing scheme/host, or with relative paths are resolved relative to the baseRequestURL, if specified. |
|
31 |
+// The following tokens are replaceable in the query of the redirectURL: |
|
32 |
+// ${url} is replaced with the current request URL, escaped as a query parameter. Example: https://www.example.com/login?then=${url} |
|
33 |
+// ${query} is replaced with the current request query, unescaped. Example: https://www.example.com/sso/oauth/authorize?${query} |
|
34 |
+func NewChallenger(baseRequestURL *url.URL, redirectURL string) oauthhandlers.AuthenticationChallenger { |
|
35 |
+ return &redirector{BaseRequestURL: baseRequestURL, RedirectURL: redirectURL} |
|
36 |
+} |
|
37 |
+ |
|
38 |
+type redirector struct { |
|
39 |
+ BaseRequestURL *url.URL |
|
40 |
+ RedirectURL string |
|
41 |
+} |
|
42 |
+ |
|
43 |
+// AuthenticationChallenge returns a Location header to the configured RedirectURL (which should return a challenge) |
|
44 |
+func (r *redirector) AuthenticationChallenge(req *http.Request) (http.Header, error) { |
|
45 |
+ redirectURL, err := buildRedirectURL(r.RedirectURL, r.BaseRequestURL, req.URL) |
|
46 |
+ if err != nil { |
|
47 |
+ return nil, err |
|
48 |
+ } |
|
49 |
+ headers := http.Header{} |
|
50 |
+ headers.Add("Location", redirectURL.String()) |
|
51 |
+ return headers, nil |
|
52 |
+} |
|
53 |
+ |
|
54 |
+// AuthenticationRedirect redirects to the configured RedirectURL |
|
55 |
+func (r *redirector) AuthenticationRedirect(w http.ResponseWriter, req *http.Request) error { |
|
56 |
+ redirectURL, err := buildRedirectURL(r.RedirectURL, r.BaseRequestURL, req.URL) |
|
57 |
+ if err != nil { |
|
58 |
+ return nil |
|
59 |
+ } |
|
60 |
+ http.Redirect(w, req, redirectURL.String(), http.StatusFound) |
|
61 |
+ return nil |
|
62 |
+} |
|
63 |
+ |
|
64 |
+func buildRedirectURL(redirectTemplate string, baseRequestURL, requestURL *url.URL) (*url.URL, error) { |
|
65 |
+ if baseRequestURL != nil { |
|
66 |
+ requestURL = baseRequestURL.ResolveReference(requestURL) |
|
67 |
+ } |
|
68 |
+ redirectURL, err := url.Parse(redirectTemplate) |
|
69 |
+ if err != nil { |
|
70 |
+ return nil, err |
|
71 |
+ } |
|
72 |
+ redirectURL.RawQuery = strings.Replace(redirectURL.RawQuery, QueryToken, requestURL.RawQuery, -1) |
|
73 |
+ redirectURL.RawQuery = strings.Replace(redirectURL.RawQuery, URLToken, url.QueryEscape(requestURL.String()), -1) |
|
74 |
+ return redirectURL, nil |
|
75 |
+} |
... | ... |
@@ -95,7 +95,22 @@ func (authHandler *unionAuthenticationHandler) AuthenticationNeeded(apiClient au |
95 | 95 |
|
96 | 96 |
if len(headers) > 0 { |
97 | 97 |
mergeHeaders(w.Header(), headers) |
98 |
- w.WriteHeader(http.StatusUnauthorized) |
|
98 |
+ |
|
99 |
+ redirectHeader := w.Header().Get("Location") |
|
100 |
+ redirectHeaders := w.Header()[http.CanonicalHeaderKey("Location")] |
|
101 |
+ challengeHeader := w.Header().Get("WWW-Authenticate") |
|
102 |
+ switch { |
|
103 |
+ case len(redirectHeader) > 0 && len(challengeHeader) > 0: |
|
104 |
+ errors = append(errors, fmt.Errorf("redirect header (Location: %s) and challenge header (WWW-Authenticate: %s) cannot both be set", redirectHeader, challengeHeader)) |
|
105 |
+ return false, kerrors.NewAggregate(errors) |
|
106 |
+ case len(redirectHeaders) > 1: |
|
107 |
+ errors = append(errors, fmt.Errorf("cannot set multiple redirect headers: %s", strings.Join(redirectHeaders, ", "))) |
|
108 |
+ return false, kerrors.NewAggregate(errors) |
|
109 |
+ case len(redirectHeader) > 0: |
|
110 |
+ w.WriteHeader(http.StatusFound) |
|
111 |
+ default: |
|
112 |
+ w.WriteHeader(http.StatusUnauthorized) |
|
113 |
+ } |
|
99 | 114 |
|
100 | 115 |
// Print Misc Warning headers (code 199) to the body |
101 | 116 |
if warnings, hasWarnings := w.Header()[http.CanonicalHeaderKey("Warning")]; hasWarnings { |
... | ... |
@@ -161,6 +161,64 @@ func TestWithChallengeErrorsAndMergedSuccess(t *testing.T) { |
161 | 161 |
if !(reflect.DeepEqual(map[string][]string(responseRecorder.HeaderMap), expectedHeader1) || reflect.DeepEqual(map[string][]string(responseRecorder.HeaderMap), expectedHeader2)) { |
162 | 162 |
t.Errorf("Expected %#v or %#v, got %#v.", expectedHeader1, expectedHeader2, responseRecorder.HeaderMap) |
163 | 163 |
} |
164 |
+ if responseRecorder.Code != 401 { |
|
165 |
+ t.Errorf("Expected 401, got %d", responseRecorder.Code) |
|
166 |
+ } |
|
167 |
+} |
|
168 |
+ |
|
169 |
+func TestWithChallengeAndRedirect(t *testing.T) { |
|
170 |
+ expectedError := "Location" |
|
171 |
+ workingChallengeHandler1 := &mockChallenger{headerName: "Location", headerValue: "https://example.com"} |
|
172 |
+ workingChallengeHandler2 := &mockChallenger{headerName: "WWW-Authenticate", headerValue: "Basic"} |
|
173 |
+ |
|
174 |
+ authHandler := NewUnionAuthenticationHandler( |
|
175 |
+ map[string]AuthenticationChallenger{ |
|
176 |
+ "first": workingChallengeHandler1, |
|
177 |
+ "second": workingChallengeHandler2, |
|
178 |
+ }, nil, nil) |
|
179 |
+ client := &testClient{&oauthapi.OAuthClient{RespondWithChallenges: true}} |
|
180 |
+ req, _ := http.NewRequest("GET", "http://example.org", nil) |
|
181 |
+ responseRecorder := httptest.NewRecorder() |
|
182 |
+ handled, err := authHandler.AuthenticationNeeded(client, responseRecorder, req) |
|
183 |
+ |
|
184 |
+ if err == nil { |
|
185 |
+ t.Errorf("Expected error, got none") |
|
186 |
+ } else if !strings.Contains(err.Error(), expectedError) { |
|
187 |
+ t.Errorf("Expected error containing %q, got %v", expectedError, err) |
|
188 |
+ } |
|
189 |
+ if handled { |
|
190 |
+ t.Error("Unexpected handling.") |
|
191 |
+ } |
|
192 |
+} |
|
193 |
+ |
|
194 |
+func TestWithRedirect(t *testing.T) { |
|
195 |
+ workingChallengeHandler1 := &mockChallenger{headerName: "Location", headerValue: "https://example.com"} |
|
196 |
+ |
|
197 |
+ // order of the array is not guaranteed |
|
198 |
+ expectedHeader1 := map[string][]string{"Location": {"https://example.com"}} |
|
199 |
+ |
|
200 |
+ authHandler := NewUnionAuthenticationHandler( |
|
201 |
+ map[string]AuthenticationChallenger{ |
|
202 |
+ "first": workingChallengeHandler1, |
|
203 |
+ }, |
|
204 |
+ nil, nil) |
|
205 |
+ client := &testClient{&oauthapi.OAuthClient{RespondWithChallenges: true}} |
|
206 |
+ req, _ := http.NewRequest("GET", "http://example.org", nil) |
|
207 |
+ responseRecorder := httptest.NewRecorder() |
|
208 |
+ handled, err := authHandler.AuthenticationNeeded(client, responseRecorder, req) |
|
209 |
+ |
|
210 |
+ if err != nil { |
|
211 |
+ t.Errorf("Unexpected error: %v", err) |
|
212 |
+ } |
|
213 |
+ if !handled { |
|
214 |
+ t.Error("Expected handling.") |
|
215 |
+ } |
|
216 |
+ if !reflect.DeepEqual(map[string][]string(responseRecorder.HeaderMap), expectedHeader1) { |
|
217 |
+ t.Errorf("Expected %#v, got %#v.", expectedHeader1, responseRecorder.HeaderMap) |
|
218 |
+ } |
|
219 |
+ if responseRecorder.Code != 302 { |
|
220 |
+ t.Errorf("Expected 302, got %d", responseRecorder.Code) |
|
221 |
+ } |
|
164 | 222 |
} |
165 | 223 |
|
166 | 224 |
type badTestClient struct { |
... | ... |
@@ -89,7 +89,7 @@ func (o *LoginOptions) getClientConfig() (*kclient.Config, error) { |
89 | 89 |
defaultServer := defaultClusterURL |
90 | 90 |
promptMsg := fmt.Sprintf("Server [%s]: ", defaultServer) |
91 | 91 |
|
92 |
- o.Server = cmdutil.PromptForStringWithDefault(o.Reader, defaultServer, promptMsg) |
|
92 |
+ o.Server = cmdutil.PromptForStringWithDefault(o.Reader, o.Out, defaultServer, promptMsg) |
|
93 | 93 |
} |
94 | 94 |
} |
95 | 95 |
} |
... | ... |
@@ -133,6 +133,9 @@ func (o *LoginOptions) getClientConfig() (*kclient.Config, error) { |
133 | 133 |
switch { |
134 | 134 |
case o.InsecureTLS: |
135 | 135 |
clientConfig.Insecure = true |
136 |
+ // insecure, clear CA info |
|
137 |
+ clientConfig.CAFile = "" |
|
138 |
+ clientConfig.CAData = nil |
|
136 | 139 |
|
137 | 140 |
// certificate issue, prompt user for insecure connection |
138 | 141 |
case clientcmd.IsCertificateAuthorityUnknown(result.Error()): |
... | ... |
@@ -148,10 +151,13 @@ func (o *LoginOptions) getClientConfig() (*kclient.Config, error) { |
148 | 148 |
fmt.Fprintln(o.Out, "The server uses a certificate signed by an unknown authority.") |
149 | 149 |
fmt.Fprintln(o.Out, "You can bypass the certificate check, but any data you send to the server could be intercepted by others.") |
150 | 150 |
|
151 |
- clientConfig.Insecure = cmdutil.PromptForBool(os.Stdin, "Use insecure connections? (y/n): ") |
|
151 |
+ clientConfig.Insecure = cmdutil.PromptForBool(os.Stdin, o.Out, "Use insecure connections? (y/n): ") |
|
152 | 152 |
if !clientConfig.Insecure { |
153 | 153 |
return nil, fmt.Errorf(clientcmd.GetPrettyMessageFor(result.Error())) |
154 | 154 |
} |
155 |
+ // insecure, clear CA info |
|
156 |
+ clientConfig.CAFile = "" |
|
157 |
+ clientConfig.CAData = nil |
|
155 | 158 |
fmt.Fprintln(o.Out) |
156 | 159 |
} |
157 | 160 |
|
... | ... |
@@ -216,39 +222,32 @@ func (o *LoginOptions) gatherAuthInfo() error { |
216 | 216 |
} |
217 | 217 |
} |
218 | 218 |
|
219 |
- // if a token was provided try to make use of it |
|
220 |
- // make sure we have a username before continuing |
|
221 |
- if !o.usernameProvided() { |
|
222 |
- if cmdutil.IsTerminal(o.Reader) { |
|
223 |
- for !o.usernameProvided() { |
|
224 |
- o.Username = cmdutil.PromptForString(o.Reader, "Username: ") |
|
225 |
- } |
|
226 |
- } |
|
227 |
- } |
|
219 |
+ // if a username was provided try to make use of it |
|
220 |
+ if o.usernameProvided() { |
|
221 |
+ // search all valid contexts with matching server stanzas to see if we have a matching user stanza |
|
222 |
+ kubeconfig := *o.StartingKubeConfig |
|
223 |
+ matchingClusters := getMatchingClusters(*clientConfig, kubeconfig) |
|
228 | 224 |
|
229 |
- // search all valid contexts with matching server stanzas to see if we have a matching user stanza |
|
230 |
- kubeconfig := *o.StartingKubeConfig |
|
231 |
- matchingClusters := getMatchingClusters(*clientConfig, kubeconfig) |
|
225 |
+ for key, context := range o.StartingKubeConfig.Contexts { |
|
226 |
+ if matchingClusters.Has(context.Cluster) { |
|
227 |
+ clientcmdConfig := kclientcmd.NewDefaultClientConfig(kubeconfig, &kclientcmd.ConfigOverrides{CurrentContext: key}) |
|
228 |
+ if kubeconfigClientConfig, err := clientcmdConfig.ClientConfig(); err == nil { |
|
229 |
+ if osClient, err := client.New(kubeconfigClientConfig); err == nil { |
|
230 |
+ if me, err := whoAmI(osClient); err == nil && (o.Username == me.Name) { |
|
231 |
+ clientConfig.BearerToken = kubeconfigClientConfig.BearerToken |
|
232 |
+ clientConfig.CertFile = kubeconfigClientConfig.CertFile |
|
233 |
+ clientConfig.CertData = kubeconfigClientConfig.CertData |
|
234 |
+ clientConfig.KeyFile = kubeconfigClientConfig.KeyFile |
|
235 |
+ clientConfig.KeyData = kubeconfigClientConfig.KeyData |
|
232 | 236 |
|
233 |
- for key, context := range o.StartingKubeConfig.Contexts { |
|
234 |
- if matchingClusters.Has(context.Cluster) { |
|
235 |
- clientcmdConfig := kclientcmd.NewDefaultClientConfig(kubeconfig, &kclientcmd.ConfigOverrides{CurrentContext: key}) |
|
236 |
- if kubeconfigClientConfig, err := clientcmdConfig.ClientConfig(); err == nil { |
|
237 |
- if osClient, err := client.New(kubeconfigClientConfig); err == nil { |
|
238 |
- if me, err := whoAmI(osClient); err == nil && (o.Username == me.Name) { |
|
239 |
- clientConfig.BearerToken = kubeconfigClientConfig.BearerToken |
|
240 |
- clientConfig.CertFile = kubeconfigClientConfig.CertFile |
|
241 |
- clientConfig.CertData = kubeconfigClientConfig.CertData |
|
242 |
- clientConfig.KeyFile = kubeconfigClientConfig.KeyFile |
|
243 |
- clientConfig.KeyData = kubeconfigClientConfig.KeyData |
|
237 |
+ o.Config = clientConfig |
|
244 | 238 |
|
245 |
- o.Config = clientConfig |
|
239 |
+ if key == o.StartingKubeConfig.CurrentContext { |
|
240 |
+ fmt.Fprintf(o.Out, "Logged into %q as %q using existing credentials.\n\n", o.Config.Host, o.Username) |
|
241 |
+ } |
|
246 | 242 |
|
247 |
- if key == o.StartingKubeConfig.CurrentContext { |
|
248 |
- fmt.Fprintf(o.Out, "Already logged into %q as %q.\n\n", o.Config.Host, o.Username) |
|
243 |
+ return nil |
|
249 | 244 |
} |
250 |
- |
|
251 |
- return nil |
|
252 | 245 |
} |
253 | 246 |
} |
254 | 247 |
} |
... | ... |
@@ -515,6 +515,22 @@ type LDAPAttributes struct { |
515 | 515 |
type RequestHeaderIdentityProvider struct { |
516 | 516 |
api.TypeMeta |
517 | 517 |
|
518 |
+ // LoginURL is a URL to redirect unauthenticated /authorize requests to |
|
519 |
+ // Unauthenticated requests from OAuth clients which expect interactive logins will be redirected here |
|
520 |
+ // ${url} is replaced with the current URL, escaped to be safe in a query parameter |
|
521 |
+ // https://www.example.com/sso-login?then=${url} |
|
522 |
+ // ${query} is replaced with the current query string |
|
523 |
+ // https://www.example.com/auth-proxy/oauth/authorize?${query} |
|
524 |
+ LoginURL string |
|
525 |
+ |
|
526 |
+ // ChallengeURL is a URL to redirect unauthenticated /authorize requests to |
|
527 |
+ // Unauthenticated requests from OAuth clients which expect WWW-Authenticate challenges will be redirected here |
|
528 |
+ // ${url} is replaced with the current URL, escaped to be safe in a query parameter |
|
529 |
+ // https://www.example.com/sso-login?then=${url} |
|
530 |
+ // ${query} is replaced with the current query string |
|
531 |
+ // https://www.example.com/auth-proxy/oauth/authorize?${query} |
|
532 |
+ ChallengeURL string |
|
533 |
+ |
|
518 | 534 |
// ClientCA is a file with the trusted signer certs. If empty, no request verification is done, and any direct request to the OAuth server can impersonate any identity from this provider, merely by setting a request header. |
519 | 535 |
ClientCA string |
520 | 536 |
// Headers is the set of headers to check for identity information |
... | ... |
@@ -501,6 +501,22 @@ type LDAPAttributes struct { |
501 | 501 |
type RequestHeaderIdentityProvider struct { |
502 | 502 |
v1.TypeMeta `json:",inline"` |
503 | 503 |
|
504 |
+ // LoginURL is a URL to redirect unauthenticated /authorize requests to |
|
505 |
+ // Unauthenticated requests from OAuth clients which expect interactive logins will be redirected here |
|
506 |
+ // ${url} is replaced with the current URL, escaped to be safe in a query parameter |
|
507 |
+ // https://www.example.com/sso-login?then=${url} |
|
508 |
+ // ${query} is replaced with the current query string |
|
509 |
+ // https://www.example.com/auth-proxy/oauth/authorize?${query} |
|
510 |
+ LoginURL string `json:"loginURL"` |
|
511 |
+ |
|
512 |
+ // ChallengeURL is a URL to redirect unauthenticated /authorize requests to |
|
513 |
+ // Unauthenticated requests from OAuth clients which expect WWW-Authenticate challenges will be redirected here |
|
514 |
+ // ${url} is replaced with the current URL, escaped to be safe in a query parameter |
|
515 |
+ // https://www.example.com/sso-login?then=${url} |
|
516 |
+ // ${query} is replaced with the current query string |
|
517 |
+ // https://www.example.com/auth-proxy/oauth/authorize?${query} |
|
518 |
+ ChallengeURL string `json:"challengeURL"` |
|
519 |
+ |
|
504 | 520 |
// ClientCA is a file with the trusted signer certs. If empty, no request verification is done, and any direct request to the OAuth server can impersonate any identity from this provider, merely by setting a request header. |
505 | 521 |
ClientCA string `json:"clientCA"` |
506 | 522 |
// Headers is the set of headers to check for identity information |
... | ... |
@@ -5,6 +5,7 @@ import ( |
5 | 5 |
"strings" |
6 | 6 |
|
7 | 7 |
"github.com/openshift/origin/pkg/auth/authenticator/password/ldappassword" |
8 |
+ "github.com/openshift/origin/pkg/auth/authenticator/redirector" |
|
8 | 9 |
"github.com/openshift/origin/pkg/cmd/server/api" |
9 | 10 |
"github.com/openshift/origin/pkg/cmd/server/api/latest" |
10 | 11 |
"github.com/openshift/origin/pkg/user/api/validation" |
... | ... |
@@ -35,6 +36,10 @@ func ValidateOAuthConfig(config *api.OAuthConfig) ValidationResults { |
35 | 35 |
|
36 | 36 |
providerNames := util.NewStringSet() |
37 | 37 |
redirectingIdentityProviders := []string{} |
38 |
+ |
|
39 |
+ challengeIssuingIdentityProviders := []string{} |
|
40 |
+ challengeRedirectingIdentityProviders := []string{} |
|
41 |
+ |
|
38 | 42 |
for i, identityProvider := range config.IdentityProviders { |
39 | 43 |
if identityProvider.UseAsLogin { |
40 | 44 |
redirectingIdentityProviders = append(redirectingIdentityProviders, identityProvider.Name) |
... | ... |
@@ -46,6 +51,16 @@ func ValidateOAuthConfig(config *api.OAuthConfig) ValidationResults { |
46 | 46 |
} |
47 | 47 |
} |
48 | 48 |
|
49 |
+ if identityProvider.UseAsChallenger { |
|
50 |
+ // RequestHeaderIdentityProvider is special, it can only react to challenge clients by redirecting them |
|
51 |
+ // Make sure we don't have more than a single redirector, and don't have a mix of challenge issuers and redirectors |
|
52 |
+ if _, isRequestHeader := identityProvider.Provider.Object.(*api.RequestHeaderIdentityProvider); isRequestHeader { |
|
53 |
+ challengeRedirectingIdentityProviders = append(challengeRedirectingIdentityProviders, identityProvider.Name) |
|
54 |
+ } else { |
|
55 |
+ challengeIssuingIdentityProviders = append(challengeIssuingIdentityProviders, identityProvider.Name) |
|
56 |
+ } |
|
57 |
+ } |
|
58 |
+ |
|
49 | 59 |
validationResults.Append(ValidateIdentityProvider(identityProvider).Prefix(fmt.Sprintf("identityProvider[%d]", i))) |
50 | 60 |
|
51 | 61 |
if len(identityProvider.Name) > 0 { |
... | ... |
@@ -57,7 +72,18 @@ func ValidateOAuthConfig(config *api.OAuthConfig) ValidationResults { |
57 | 57 |
} |
58 | 58 |
|
59 | 59 |
if len(redirectingIdentityProviders) > 1 { |
60 |
- validationResults.AddErrors(fielderrors.NewFieldInvalid("identityProviders", config.IdentityProviders, fmt.Sprintf("only one identity provider can support login for a browser, found: %v", redirectingIdentityProviders))) |
|
60 |
+ validationResults.AddErrors(fielderrors.NewFieldInvalid("identityProviders", "login", fmt.Sprintf("only one identity provider can support login for a browser, found: %v", strings.Join(redirectingIdentityProviders, ", ")))) |
|
61 |
+ } |
|
62 |
+ if len(challengeRedirectingIdentityProviders) > 1 { |
|
63 |
+ validationResults.AddErrors(fielderrors.NewFieldInvalid("identityProviders", "challenge", fmt.Sprintf("only one identity provider can redirect clients requesting an authentication challenge, found: %v", strings.Join(challengeRedirectingIdentityProviders, ", ")))) |
|
64 |
+ } |
|
65 |
+ if len(challengeRedirectingIdentityProviders) > 0 && len(challengeIssuingIdentityProviders) > 0 { |
|
66 |
+ validationResults.AddErrors( |
|
67 |
+ fielderrors.NewFieldInvalid("identityProviders", "challenge", fmt.Sprintf( |
|
68 |
+ "cannot mix providers that redirect clients requesting auth challenges (%s) with providers issuing challenges to those clients (%s)", |
|
69 |
+ strings.Join(challengeRedirectingIdentityProviders, ", "), |
|
70 |
+ strings.Join(challengeIssuingIdentityProviders, ", "), |
|
71 |
+ ))) |
|
61 | 72 |
} |
62 | 73 |
|
63 | 74 |
return validationResults |
... | ... |
@@ -78,7 +104,7 @@ func ValidateIdentityProvider(identityProvider api.IdentityProvider) ValidationR |
78 | 78 |
} else { |
79 | 79 |
switch provider := identityProvider.Provider.Object.(type) { |
80 | 80 |
case (*api.RequestHeaderIdentityProvider): |
81 |
- validationResults.AddErrors(ValidateRequestHeaderIdentityProvider(provider, identityProvider)...) |
|
81 |
+ validationResults.Append(ValidateRequestHeaderIdentityProvider(provider, identityProvider)) |
|
82 | 82 |
|
83 | 83 |
case (*api.BasicAuthPasswordIdentityProvider): |
84 | 84 |
validationResults.AddErrors(ValidateRemoteConnectionInfo(provider.RemoteConnectionInfo).Prefix("provider")...) |
... | ... |
@@ -152,23 +178,59 @@ func ValidateLDAPIdentityProvider(provider *api.LDAPPasswordIdentityProvider, id |
152 | 152 |
return validationResults |
153 | 153 |
} |
154 | 154 |
|
155 |
-func ValidateRequestHeaderIdentityProvider(provider *api.RequestHeaderIdentityProvider, identityProvider api.IdentityProvider) fielderrors.ValidationErrorList { |
|
156 |
- allErrs := fielderrors.ValidationErrorList{} |
|
155 |
+func ValidateRequestHeaderIdentityProvider(provider *api.RequestHeaderIdentityProvider, identityProvider api.IdentityProvider) ValidationResults { |
|
156 |
+ validationResults := ValidationResults{} |
|
157 | 157 |
|
158 | 158 |
if len(provider.ClientCA) > 0 { |
159 |
- allErrs = append(allErrs, ValidateFile(provider.ClientCA, "provider.clientCA")...) |
|
159 |
+ validationResults.AddErrors(ValidateFile(provider.ClientCA, "provider.clientCA")...) |
|
160 | 160 |
} |
161 | 161 |
if len(provider.Headers) == 0 { |
162 |
- allErrs = append(allErrs, fielderrors.NewFieldRequired("provider.headers")) |
|
162 |
+ validationResults.AddErrors(fielderrors.NewFieldRequired("provider.headers")) |
|
163 | 163 |
} |
164 |
- if identityProvider.UseAsChallenger { |
|
165 |
- allErrs = append(allErrs, fielderrors.NewFieldInvalid("challenge", identityProvider.UseAsChallenger, "request header providers cannot be used for challenges")) |
|
164 |
+ if identityProvider.UseAsChallenger && len(provider.ChallengeURL) == 0 { |
|
165 |
+ err := fielderrors.NewFieldRequired("provider.challengeURL") |
|
166 |
+ err.Detail = "challengeURL is required if challenge=true" |
|
167 |
+ validationResults.AddErrors(err) |
|
166 | 168 |
} |
167 |
- if identityProvider.UseAsLogin { |
|
168 |
- allErrs = append(allErrs, fielderrors.NewFieldInvalid("login", identityProvider.UseAsChallenger, "request header providers cannot be used for browser login")) |
|
169 |
+ if identityProvider.UseAsLogin && len(provider.LoginURL) == 0 { |
|
170 |
+ err := fielderrors.NewFieldRequired("provider.loginURL") |
|
171 |
+ err.Detail = "loginURL is required if login=true" |
|
172 |
+ validationResults.AddErrors(err) |
|
169 | 173 |
} |
170 | 174 |
|
171 |
- return allErrs |
|
175 |
+ if len(provider.ChallengeURL) > 0 { |
|
176 |
+ url, urlErrs := ValidateURL(provider.ChallengeURL, "provider.challengeURL") |
|
177 |
+ validationResults.AddErrors(urlErrs...) |
|
178 |
+ if len(urlErrs) == 0 && !strings.Contains(url.RawQuery, redirector.URLToken) && !strings.Contains(url.RawQuery, redirector.QueryToken) { |
|
179 |
+ validationResults.AddWarnings( |
|
180 |
+ fielderrors.NewFieldInvalid( |
|
181 |
+ "provider.challengeURL", |
|
182 |
+ provider.ChallengeURL, |
|
183 |
+ fmt.Sprintf("query does not include %q or %q, redirect will not preserve original authorize parameters", redirector.URLToken, redirector.QueryToken), |
|
184 |
+ ), |
|
185 |
+ ) |
|
186 |
+ } |
|
187 |
+ } |
|
188 |
+ if len(provider.LoginURL) > 0 { |
|
189 |
+ url, urlErrs := ValidateURL(provider.LoginURL, "provider.loginURL") |
|
190 |
+ validationResults.AddErrors(urlErrs...) |
|
191 |
+ if len(urlErrs) == 0 && !strings.Contains(url.RawQuery, redirector.URLToken) && !strings.Contains(url.RawQuery, redirector.QueryToken) { |
|
192 |
+ validationResults.AddWarnings( |
|
193 |
+ fielderrors.NewFieldInvalid( |
|
194 |
+ "provider.loginURL", |
|
195 |
+ provider.LoginURL, |
|
196 |
+ fmt.Sprintf("query does not include %q or %q, redirect will not preserve original authorize parameters", redirector.URLToken, redirector.QueryToken), |
|
197 |
+ ), |
|
198 |
+ ) |
|
199 |
+ } |
|
200 |
+ } |
|
201 |
+ |
|
202 |
+ // Warn if it looks like they expect direct requests to the OAuth endpoints, and have not secured the header checking with a client certificate check |
|
203 |
+ if len(provider.ClientCA) == 0 && (len(provider.ChallengeURL) > 0 || len(provider.LoginURL) > 0) { |
|
204 |
+ validationResults.AddWarnings(fielderrors.NewFieldInvalid("provider.clientCA", "", "if no clientCA is set, no request verification is done, and any request directly against the OAuth server can impersonate any identity from this provider")) |
|
205 |
+ } |
|
206 |
+ |
|
207 |
+ return validationResults |
|
172 | 208 |
} |
173 | 209 |
|
174 | 210 |
func ValidateOAuthIdentityProvider(clientID, clientSecret string, challenge bool) fielderrors.ValidationErrorList { |
... | ... |
@@ -28,6 +28,7 @@ import ( |
28 | 28 |
"github.com/openshift/origin/pkg/auth/authenticator/password/denypassword" |
29 | 29 |
"github.com/openshift/origin/pkg/auth/authenticator/password/htpasswd" |
30 | 30 |
"github.com/openshift/origin/pkg/auth/authenticator/password/ldappassword" |
31 |
+ "github.com/openshift/origin/pkg/auth/authenticator/redirector" |
|
31 | 32 |
"github.com/openshift/origin/pkg/auth/authenticator/request/basicauthrequest" |
32 | 33 |
"github.com/openshift/origin/pkg/auth/authenticator/request/headerrequest" |
33 | 34 |
"github.com/openshift/origin/pkg/auth/authenticator/request/unionrequest" |
... | ... |
@@ -323,6 +324,7 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand |
323 | 323 |
for _, identityProvider := range c.Options.IdentityProviders { |
324 | 324 |
identityMapper := identitymapper.NewAlwaysCreateUserIdentityToUserMapper(c.IdentityRegistry, c.UserRegistry) |
325 | 325 |
|
326 |
+ // TODO: refactor handler building per type |
|
326 | 327 |
if configapi.IsPasswordAuthenticator(identityProvider) { |
327 | 328 |
passwordAuth, err := c.getPasswordAuthenticator(identityProvider) |
328 | 329 |
if err != nil { |
... | ... |
@@ -338,14 +340,15 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand |
338 | 338 |
} |
339 | 339 |
passwordSuccessHandler := handlers.AuthenticationSuccessHandlers{c.SessionAuth, redirectSuccessHandler{}} |
340 | 340 |
|
341 |
- redirectors["login"] = &redirector{RedirectURL: OpenShiftLoginPrefix, ThenParam: "then"} |
|
341 |
+ // Since we're redirecting to a local login page, we don't need to force absolute URL resolution |
|
342 |
+ redirectors["login-"+identityProvider.Name+"-redirect"] = redirector.NewRedirector(nil, OpenShiftLoginPrefix+"?then=${url}") |
|
342 | 343 |
login := login.NewLogin(c.getCSRF(), &callbackPasswordAuthenticator{passwordAuth, passwordSuccessHandler}, login.DefaultLoginFormRenderer) |
343 | 344 |
login.Install(mux, OpenShiftLoginPrefix) |
344 | 345 |
} |
345 | 346 |
if identityProvider.UseAsChallenger { |
346 |
- challengers["login"] = passwordchallenger.NewBasicAuthChallenger("openshift") |
|
347 |
+ // For now, all password challenges share a single basic challenger, since they'll all respond to any basic credentials |
|
348 |
+ challengers["basic-challenge"] = passwordchallenger.NewBasicAuthChallenger("openshift") |
|
347 | 349 |
} |
348 |
- |
|
349 | 350 |
} else if configapi.IsOAuthIdentityProvider(identityProvider) { |
350 | 351 |
oauthProvider, err := c.getOAuthProvider(identityProvider) |
351 | 352 |
if err != nil { |
... | ... |
@@ -374,11 +377,23 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand |
374 | 374 |
|
375 | 375 |
mux.Handle(callbackPath, oauthHandler) |
376 | 376 |
if identityProvider.UseAsLogin { |
377 |
- redirectors[identityProvider.Name] = oauthHandler |
|
377 |
+ redirectors["oauth-"+identityProvider.Name+"-redirect"] = oauthHandler |
|
378 | 378 |
} |
379 | 379 |
if identityProvider.UseAsChallenger { |
380 | 380 |
return nil, errors.New("oauth identity providers cannot issue challenges") |
381 | 381 |
} |
382 |
+ } else if requestHeaderProvider, isRequestHeader := identityProvider.Provider.Object.(*configapi.RequestHeaderIdentityProvider); isRequestHeader { |
|
383 |
+ // We might be redirecting to an external site, we need to fully resolve the request URL to the public master |
|
384 |
+ baseRequestURL, err := url.Parse(c.Options.MasterPublicURL + OpenShiftOAuthAPIPrefix + osinserver.AuthorizePath) |
|
385 |
+ if err != nil { |
|
386 |
+ return nil, err |
|
387 |
+ } |
|
388 |
+ if identityProvider.UseAsChallenger { |
|
389 |
+ challengers["requestheader-"+identityProvider.Name+"-redirect"] = redirector.NewChallenger(baseRequestURL, requestHeaderProvider.ChallengeURL) |
|
390 |
+ } |
|
391 |
+ if identityProvider.UseAsLogin { |
|
392 |
+ redirectors["requestheader-"+identityProvider.Name+"-redirect"] = redirector.NewRedirector(baseRequestURL, requestHeaderProvider.LoginURL) |
|
393 |
+ } |
|
382 | 394 |
} |
383 | 395 |
} |
384 | 396 |
|
... | ... |
@@ -554,27 +569,6 @@ func (c *AuthConfig) getAuthenticationRequestHandler() (authenticator.Request, e |
554 | 554 |
return authRequestHandler, nil |
555 | 555 |
} |
556 | 556 |
|
557 |
-// redirector captures the original request url as a "then" param in a redirect to a login flow |
|
558 |
-type redirector struct { |
|
559 |
- RedirectURL string |
|
560 |
- ThenParam string |
|
561 |
-} |
|
562 |
- |
|
563 |
-// AuthenticationRedirect redirects HTTP request to authorization URL |
|
564 |
-func (auth *redirector) AuthenticationRedirect(w http.ResponseWriter, req *http.Request) error { |
|
565 |
- redirectURL, err := url.Parse(auth.RedirectURL) |
|
566 |
- if err != nil { |
|
567 |
- return err |
|
568 |
- } |
|
569 |
- if len(auth.ThenParam) != 0 { |
|
570 |
- redirectURL.RawQuery = url.Values{ |
|
571 |
- auth.ThenParam: {req.URL.String()}, |
|
572 |
- }.Encode() |
|
573 |
- } |
|
574 |
- http.Redirect(w, req, redirectURL.String(), http.StatusFound) |
|
575 |
- return nil |
|
576 |
-} |
|
577 |
- |
|
578 | 557 |
// callbackPasswordAuthenticator combines password auth, successful login callback, |
579 | 558 |
// and "then" param redirection |
580 | 559 |
type callbackPasswordAuthenticator struct { |
... | ... |
@@ -12,13 +12,21 @@ import ( |
12 | 12 |
) |
13 | 13 |
|
14 | 14 |
// PromptForString takes an io.Reader and prompts for user input if it's a terminal, returning the result. |
15 |
-func PromptForString(r io.Reader, format string, a ...interface{}) string { |
|
16 |
- fmt.Printf(format, a...) |
|
15 |
+func PromptForString(r io.Reader, w io.Writer, format string, a ...interface{}) string { |
|
16 |
+ if w == nil { |
|
17 |
+ w = os.Stdout |
|
18 |
+ } |
|
19 |
+ |
|
20 |
+ fmt.Fprintf(w, format, a...) |
|
17 | 21 |
return readInput(r) |
18 | 22 |
} |
19 | 23 |
|
20 | 24 |
// PromptForPasswordString prompts for user input by disabling echo in terminal, useful for password prompt. |
21 |
-func PromptForPasswordString(r io.Reader, format string, a ...interface{}) string { |
|
25 |
+func PromptForPasswordString(r io.Reader, w io.Writer, format string, a ...interface{}) string { |
|
26 |
+ if w == nil { |
|
27 |
+ w = os.Stdout |
|
28 |
+ } |
|
29 |
+ |
|
22 | 30 |
if file, ok := r.(*os.File); ok { |
23 | 31 |
inFd := file.Fd() |
24 | 32 |
|
... | ... |
@@ -26,10 +34,10 @@ func PromptForPasswordString(r io.Reader, format string, a ...interface{}) strin |
26 | 26 |
oldState, err := term.SaveState(inFd) |
27 | 27 |
if err != nil { |
28 | 28 |
glog.V(3).Infof("Unable to save terminal state") |
29 |
- return PromptForString(r, format, a...) |
|
29 |
+ return PromptForString(r, w, format, a...) |
|
30 | 30 |
} |
31 | 31 |
|
32 |
- fmt.Printf(format, a...) |
|
32 |
+ fmt.Fprintf(w, format, a...) |
|
33 | 33 |
|
34 | 34 |
term.DisableEcho(inFd, oldState) |
35 | 35 |
|
... | ... |
@@ -37,22 +45,26 @@ func PromptForPasswordString(r io.Reader, format string, a ...interface{}) strin |
37 | 37 |
|
38 | 38 |
defer term.RestoreTerminal(inFd, oldState) |
39 | 39 |
|
40 |
- fmt.Printf("\n") |
|
40 |
+ fmt.Fprintf(w, "\n") |
|
41 | 41 |
|
42 | 42 |
return input |
43 | 43 |
} |
44 | 44 |
glog.V(3).Infof("Stdin is not a terminal") |
45 |
- return PromptForString(r, format, a...) |
|
45 |
+ return PromptForString(r, w, format, a...) |
|
46 | 46 |
} |
47 |
- return PromptForString(r, format, a...) |
|
47 |
+ return PromptForString(r, w, format, a...) |
|
48 | 48 |
} |
49 | 49 |
|
50 | 50 |
// PromptForBool prompts for user input of a boolean value. The accepted values are: |
51 | 51 |
// yes, y, true, t, 1 (not case sensitive) |
52 | 52 |
// no, n, false, f, 0 (not case sensitive) |
53 | 53 |
// A valid answer is mandatory so it will keep asking until an answer is provided. |
54 |
-func PromptForBool(r io.Reader, format string, a ...interface{}) bool { |
|
55 |
- str := PromptForString(r, format, a...) |
|
54 |
+func PromptForBool(r io.Reader, w io.Writer, format string, a ...interface{}) bool { |
|
55 |
+ if w == nil { |
|
56 |
+ w = os.Stdout |
|
57 |
+ } |
|
58 |
+ |
|
59 |
+ str := PromptForString(r, w, format, a...) |
|
56 | 60 |
switch strings.ToLower(str) { |
57 | 61 |
case "1", "t", "true", "y", "yes": |
58 | 62 |
return true |
... | ... |
@@ -60,12 +72,16 @@ func PromptForBool(r io.Reader, format string, a ...interface{}) bool { |
60 | 60 |
return false |
61 | 61 |
} |
62 | 62 |
fmt.Println("Please enter 'yes' or 'no'.") |
63 |
- return PromptForBool(r, format, a...) |
|
63 |
+ return PromptForBool(r, w, format, a...) |
|
64 | 64 |
} |
65 | 65 |
|
66 | 66 |
// PromptForStringWithDefault prompts for user input but take a default in case nothing is provided. |
67 |
-func PromptForStringWithDefault(r io.Reader, def string, format string, a ...interface{}) string { |
|
68 |
- s := PromptForString(r, format, a...) |
|
67 |
+func PromptForStringWithDefault(r io.Reader, w io.Writer, def string, format string, a ...interface{}) string { |
|
68 |
+ if w == nil { |
|
69 |
+ w = os.Stdout |
|
70 |
+ } |
|
71 |
+ |
|
72 |
+ s := PromptForString(r, w, format, a...) |
|
69 | 73 |
if len(s) == 0 { |
70 | 74 |
return def |
71 | 75 |
} |
72 | 76 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,119 @@ |
0 |
+package tokencmd |
|
1 |
+ |
|
2 |
+import ( |
|
3 |
+ "encoding/base64" |
|
4 |
+ "fmt" |
|
5 |
+ "io" |
|
6 |
+ "net/http" |
|
7 |
+ "os" |
|
8 |
+ "regexp" |
|
9 |
+ |
|
10 |
+ "github.com/golang/glog" |
|
11 |
+ |
|
12 |
+ "github.com/openshift/origin/pkg/cmd/util" |
|
13 |
+) |
|
14 |
+ |
|
15 |
+type BasicChallengeHandler struct { |
|
16 |
+ // Host is the server being authenticated to. Used only for displaying messages when prompting for username/password |
|
17 |
+ Host string |
|
18 |
+ |
|
19 |
+ // Reader is used to prompt for username/password. If nil, no prompting is done |
|
20 |
+ Reader io.Reader |
|
21 |
+ // Writer is used to output prompts. If nil, stdout is used |
|
22 |
+ Writer io.Writer |
|
23 |
+ |
|
24 |
+ // Username is the username to use when challenged. If empty, a prompt is issued to a non-nil Reader |
|
25 |
+ Username string |
|
26 |
+ // Password is the password to use when challenged. If empty, a prompt is issued to a non-nil Reader |
|
27 |
+ Password string |
|
28 |
+ |
|
29 |
+ // handled tracks whether this handler has already handled a challenge. |
|
30 |
+ handled bool |
|
31 |
+ // prompted tracks whether this handler has already prompted for a username and/or password. |
|
32 |
+ prompted bool |
|
33 |
+} |
|
34 |
+ |
|
35 |
+func (c *BasicChallengeHandler) CanHandle(headers http.Header) bool { |
|
36 |
+ isBasic, _ := basicRealm(headers) |
|
37 |
+ return isBasic |
|
38 |
+} |
|
39 |
+func (c *BasicChallengeHandler) HandleChallenge(headers http.Header) (http.Header, bool, error) { |
|
40 |
+ if c.prompted { |
|
41 |
+ glog.V(2).Info("already prompted for challenge, won't prompt again") |
|
42 |
+ return nil, false, nil |
|
43 |
+ } |
|
44 |
+ if c.Reader == nil && c.handled { |
|
45 |
+ glog.V(2).Info("already handled basic challenge, no reader to alter inputs") |
|
46 |
+ return nil, false, nil |
|
47 |
+ } |
|
48 |
+ |
|
49 |
+ username := c.Username |
|
50 |
+ password := c.Password |
|
51 |
+ |
|
52 |
+ missingUsername := len(username) == 0 |
|
53 |
+ missingPassword := len(password) == 0 |
|
54 |
+ |
|
55 |
+ if (missingUsername || missingPassword) && c.Reader != nil { |
|
56 |
+ w := c.Writer |
|
57 |
+ if w == nil { |
|
58 |
+ w = os.Stdout |
|
59 |
+ } |
|
60 |
+ |
|
61 |
+ if _, realm := basicRealm(headers); len(realm) > 0 { |
|
62 |
+ fmt.Fprintf(w, "Authentication required for %s (%s)\n", c.Host, realm) |
|
63 |
+ } else { |
|
64 |
+ fmt.Fprintf(w, "Authentication required for %s\n", c.Host) |
|
65 |
+ } |
|
66 |
+ if missingUsername { |
|
67 |
+ username = util.PromptForString(c.Reader, w, "Username: ") |
|
68 |
+ } else { |
|
69 |
+ fmt.Fprintf(w, "Username: %s\n", username) |
|
70 |
+ } |
|
71 |
+ if missingPassword { |
|
72 |
+ password = util.PromptForPasswordString(c.Reader, w, "Password: ") |
|
73 |
+ } |
|
74 |
+ // remember so we don't re-prompt |
|
75 |
+ c.prompted = true |
|
76 |
+ } |
|
77 |
+ |
|
78 |
+ if len(username) > 0 || len(password) > 0 { |
|
79 |
+ responseHeaders := http.Header{} |
|
80 |
+ responseHeaders.Set("Authorization", getBasicHeader(username, password)) |
|
81 |
+ // remember so we don't re-handle non-interactively |
|
82 |
+ c.handled = true |
|
83 |
+ return responseHeaders, true, nil |
|
84 |
+ } |
|
85 |
+ |
|
86 |
+ glog.V(2).Info("no username or password available") |
|
87 |
+ return nil, false, nil |
|
88 |
+} |
|
89 |
+ |
|
90 |
+// if any of these match a WWW-Authenticate header, it is a basic challenge |
|
91 |
+// capturing group 1 (if present) should contain the realm |
|
92 |
+var basicRegexes = []*regexp.Regexp{ |
|
93 |
+ // quoted realm |
|
94 |
+ regexp.MustCompile(`(?i)^\s*basic\s+realm\s*=\s*"(.*?)"\s*(,|$)`), |
|
95 |
+ // token realm |
|
96 |
+ regexp.MustCompile(`(?i)^\s*basic\s+realm\s*=\s*(.*?)\s*(,|$)`), |
|
97 |
+ // no realm |
|
98 |
+ regexp.MustCompile(`(?i)^\s*basic(?:\s+|$)`), |
|
99 |
+} |
|
100 |
+ |
|
101 |
+func basicRealm(headers http.Header) (bool, string) { |
|
102 |
+ for _, challengeHeader := range headers[http.CanonicalHeaderKey("WWW-Authenticate")] { |
|
103 |
+ for _, r := range basicRegexes { |
|
104 |
+ if matches := r.FindStringSubmatch(challengeHeader); matches != nil { |
|
105 |
+ if len(matches) > 1 { |
|
106 |
+ // We got a realm as well |
|
107 |
+ return true, matches[1] |
|
108 |
+ } |
|
109 |
+ // No realm, but still basic |
|
110 |
+ return true, "" |
|
111 |
+ } |
|
112 |
+ } |
|
113 |
+ } |
|
114 |
+ return false, "" |
|
115 |
+} |
|
116 |
+func getBasicHeader(username, password string) string { |
|
117 |
+ return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) |
|
118 |
+} |
0 | 119 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,296 @@ |
0 |
+package tokencmd |
|
1 |
+ |
|
2 |
+import ( |
|
3 |
+ "bytes" |
|
4 |
+ "net/http" |
|
5 |
+ "reflect" |
|
6 |
+ "testing" |
|
7 |
+) |
|
8 |
+ |
|
9 |
+var ( |
|
10 |
+ AUTHORIZATION = http.CanonicalHeaderKey("Authorization") |
|
11 |
+ WWW_AUTHENTICATE = http.CanonicalHeaderKey("WWW-Authenticate") |
|
12 |
+) |
|
13 |
+ |
|
14 |
+type Challenge struct { |
|
15 |
+ Headers http.Header |
|
16 |
+ |
|
17 |
+ ExpectedCanHandle bool |
|
18 |
+ ExpectedHeaders http.Header |
|
19 |
+ ExpectedHandled bool |
|
20 |
+ ExpectedErr error |
|
21 |
+ ExpectedPrompt string |
|
22 |
+} |
|
23 |
+ |
|
24 |
+func TestHandleChallenge(t *testing.T) { |
|
25 |
+ |
|
26 |
+ basicChallenge := http.Header{WWW_AUTHENTICATE: []string{`Basic realm="myrealm"`}} |
|
27 |
+ |
|
28 |
+ testCases := map[string]struct { |
|
29 |
+ Handler *BasicChallengeHandler |
|
30 |
+ Challenges []Challenge |
|
31 |
+ }{ |
|
32 |
+ "non-interactive with no defaults": { |
|
33 |
+ Handler: &BasicChallengeHandler{ |
|
34 |
+ Host: "myhost", |
|
35 |
+ Reader: nil, |
|
36 |
+ Username: "", |
|
37 |
+ Password: "", |
|
38 |
+ }, |
|
39 |
+ Challenges: []Challenge{ |
|
40 |
+ { |
|
41 |
+ Headers: basicChallenge, |
|
42 |
+ ExpectedCanHandle: true, |
|
43 |
+ ExpectedHeaders: nil, |
|
44 |
+ ExpectedHandled: false, |
|
45 |
+ ExpectedErr: nil, |
|
46 |
+ ExpectedPrompt: "", |
|
47 |
+ }, |
|
48 |
+ }, |
|
49 |
+ }, |
|
50 |
+ |
|
51 |
+ "non-interactive challenge with defaults": { |
|
52 |
+ Handler: &BasicChallengeHandler{ |
|
53 |
+ Host: "myhost", |
|
54 |
+ Reader: nil, |
|
55 |
+ Username: "myuser", |
|
56 |
+ Password: "mypassword", |
|
57 |
+ }, |
|
58 |
+ Challenges: []Challenge{ |
|
59 |
+ { |
|
60 |
+ Headers: basicChallenge, |
|
61 |
+ ExpectedCanHandle: true, |
|
62 |
+ ExpectedHeaders: http.Header{AUTHORIZATION: []string{getBasicHeader("myuser", "mypassword")}}, |
|
63 |
+ ExpectedHandled: true, |
|
64 |
+ ExpectedErr: nil, |
|
65 |
+ ExpectedPrompt: "", |
|
66 |
+ }, |
|
67 |
+ { |
|
68 |
+ Headers: basicChallenge, |
|
69 |
+ ExpectedCanHandle: true, |
|
70 |
+ ExpectedHeaders: nil, |
|
71 |
+ ExpectedHandled: false, |
|
72 |
+ ExpectedErr: nil, |
|
73 |
+ ExpectedPrompt: "", |
|
74 |
+ }, |
|
75 |
+ }, |
|
76 |
+ }, |
|
77 |
+ |
|
78 |
+ "interactive challenge with default user": { |
|
79 |
+ Handler: &BasicChallengeHandler{ |
|
80 |
+ Host: "myhost", |
|
81 |
+ Reader: bytes.NewBufferString("mypassword\n"), |
|
82 |
+ Username: "myuser", |
|
83 |
+ Password: "", |
|
84 |
+ }, |
|
85 |
+ Challenges: []Challenge{ |
|
86 |
+ { |
|
87 |
+ Headers: basicChallenge, |
|
88 |
+ ExpectedCanHandle: true, |
|
89 |
+ ExpectedHeaders: http.Header{AUTHORIZATION: []string{getBasicHeader("myuser", "mypassword")}}, |
|
90 |
+ ExpectedHandled: true, |
|
91 |
+ ExpectedErr: nil, |
|
92 |
+ ExpectedPrompt: `Authentication required for myhost (myrealm) |
|
93 |
+Username: myuser |
|
94 |
+Password: `, |
|
95 |
+ }, |
|
96 |
+ }, |
|
97 |
+ }, |
|
98 |
+ |
|
99 |
+ "interactive challenge": { |
|
100 |
+ Handler: &BasicChallengeHandler{ |
|
101 |
+ Host: "myhost", |
|
102 |
+ Reader: bytes.NewBufferString("myuser\nmypassword\n"), |
|
103 |
+ Username: "", |
|
104 |
+ Password: "", |
|
105 |
+ }, |
|
106 |
+ Challenges: []Challenge{ |
|
107 |
+ { |
|
108 |
+ Headers: basicChallenge, |
|
109 |
+ ExpectedCanHandle: true, |
|
110 |
+ ExpectedHeaders: http.Header{AUTHORIZATION: []string{getBasicHeader("myuser", "mypassword")}}, |
|
111 |
+ ExpectedHandled: true, |
|
112 |
+ ExpectedErr: nil, |
|
113 |
+ ExpectedPrompt: `Authentication required for myhost (myrealm) |
|
114 |
+Username: Password: `, |
|
115 |
+ }, |
|
116 |
+ { |
|
117 |
+ Headers: basicChallenge, |
|
118 |
+ ExpectedCanHandle: true, |
|
119 |
+ ExpectedHeaders: nil, |
|
120 |
+ ExpectedHandled: false, |
|
121 |
+ ExpectedErr: nil, |
|
122 |
+ ExpectedPrompt: ``, |
|
123 |
+ }, |
|
124 |
+ }, |
|
125 |
+ }, |
|
126 |
+ } |
|
127 |
+ |
|
128 |
+ for k, tc := range testCases { |
|
129 |
+ for i, challenge := range tc.Challenges { |
|
130 |
+ out := &bytes.Buffer{} |
|
131 |
+ tc.Handler.Writer = out |
|
132 |
+ |
|
133 |
+ canHandle := tc.Handler.CanHandle(challenge.Headers) |
|
134 |
+ if canHandle != challenge.ExpectedCanHandle { |
|
135 |
+ t.Errorf("%s: %d: Expected CanHandle=%v, got %v", k, i, challenge.ExpectedCanHandle, canHandle) |
|
136 |
+ } |
|
137 |
+ |
|
138 |
+ if canHandle { |
|
139 |
+ headers, handled, err := tc.Handler.HandleChallenge(challenge.Headers) |
|
140 |
+ if !reflect.DeepEqual(headers, challenge.ExpectedHeaders) { |
|
141 |
+ t.Errorf("%s: %d: Expected headers\n\t%#v\ngot\n\t%#v", k, i, challenge.ExpectedHeaders, headers) |
|
142 |
+ } |
|
143 |
+ if handled != challenge.ExpectedHandled { |
|
144 |
+ t.Errorf("%s: %d: Expected handled=%v, got %v", k, i, challenge.ExpectedHandled, handled) |
|
145 |
+ } |
|
146 |
+ if err != challenge.ExpectedErr { |
|
147 |
+ t.Errorf("%s: %d: Expected err=%v, got %v", k, i, challenge.ExpectedErr, err) |
|
148 |
+ } |
|
149 |
+ if out.String() != challenge.ExpectedPrompt { |
|
150 |
+ t.Errorf("%s: %d: Expected prompt %q, got %q", k, i, challenge.ExpectedPrompt, out.String()) |
|
151 |
+ } |
|
152 |
+ } |
|
153 |
+ } |
|
154 |
+ } |
|
155 |
+} |
|
156 |
+ |
|
157 |
+func TestBasicRealm(t *testing.T) { |
|
158 |
+ |
|
159 |
+ testCases := map[string]struct { |
|
160 |
+ Headers http.Header |
|
161 |
+ ExpectedBasic bool |
|
162 |
+ ExpectedRealm string |
|
163 |
+ }{ |
|
164 |
+ "empty": { |
|
165 |
+ Headers: http.Header{}, |
|
166 |
+ ExpectedBasic: false, |
|
167 |
+ ExpectedRealm: ``, |
|
168 |
+ }, |
|
169 |
+ |
|
170 |
+ "non-challenge": { |
|
171 |
+ Headers: http.Header{ |
|
172 |
+ "test": []string{`value`}, |
|
173 |
+ }, |
|
174 |
+ ExpectedBasic: false, |
|
175 |
+ ExpectedRealm: ``, |
|
176 |
+ }, |
|
177 |
+ |
|
178 |
+ "non-basic": { |
|
179 |
+ Headers: http.Header{ |
|
180 |
+ WWW_AUTHENTICATE: []string{ |
|
181 |
+ `basicrealm="myrealm"`, |
|
182 |
+ `digest basic="realm"`, |
|
183 |
+ }, |
|
184 |
+ }, |
|
185 |
+ ExpectedBasic: false, |
|
186 |
+ ExpectedRealm: ``, |
|
187 |
+ }, |
|
188 |
+ |
|
189 |
+ "basic multiple www-authenticate headers": { |
|
190 |
+ Headers: http.Header{ |
|
191 |
+ WWW_AUTHENTICATE: []string{ |
|
192 |
+ `digest realm="digestrealm"`, |
|
193 |
+ `basic realm="Foo"`, |
|
194 |
+ `foo bar="baz"`, |
|
195 |
+ }, |
|
196 |
+ }, |
|
197 |
+ ExpectedBasic: true, |
|
198 |
+ ExpectedRealm: `Foo`, |
|
199 |
+ }, |
|
200 |
+ |
|
201 |
+ "basic no realm": { |
|
202 |
+ Headers: http.Header{ |
|
203 |
+ WWW_AUTHENTICATE: []string{`basic`}, |
|
204 |
+ }, |
|
205 |
+ ExpectedBasic: true, |
|
206 |
+ ExpectedRealm: ``, |
|
207 |
+ }, |
|
208 |
+ |
|
209 |
+ "basic other param": { |
|
210 |
+ Headers: http.Header{ |
|
211 |
+ WWW_AUTHENTICATE: []string{`basic otherparam="othervalue"`}, |
|
212 |
+ }, |
|
213 |
+ ExpectedBasic: true, |
|
214 |
+ ExpectedRealm: ``, |
|
215 |
+ }, |
|
216 |
+ |
|
217 |
+ "basic token realm": { |
|
218 |
+ Headers: http.Header{ |
|
219 |
+ WWW_AUTHENTICATE: []string{`basic realm=Foo Bar `}, |
|
220 |
+ }, |
|
221 |
+ ExpectedBasic: true, |
|
222 |
+ ExpectedRealm: `Foo Bar`, |
|
223 |
+ }, |
|
224 |
+ |
|
225 |
+ "basic quoted realm": { |
|
226 |
+ Headers: http.Header{ |
|
227 |
+ WWW_AUTHENTICATE: []string{`basic realm="Foo Bar"`}, |
|
228 |
+ }, |
|
229 |
+ ExpectedBasic: true, |
|
230 |
+ ExpectedRealm: `Foo Bar`, |
|
231 |
+ }, |
|
232 |
+ |
|
233 |
+ "basic case-insensitive scheme": { |
|
234 |
+ Headers: http.Header{ |
|
235 |
+ WWW_AUTHENTICATE: []string{`BASIC realm="Foo"`}, |
|
236 |
+ }, |
|
237 |
+ ExpectedBasic: true, |
|
238 |
+ ExpectedRealm: `Foo`, |
|
239 |
+ }, |
|
240 |
+ |
|
241 |
+ "basic case-insensitive realm": { |
|
242 |
+ Headers: http.Header{ |
|
243 |
+ WWW_AUTHENTICATE: []string{`basic REALM="Foo"`}, |
|
244 |
+ }, |
|
245 |
+ ExpectedBasic: true, |
|
246 |
+ ExpectedRealm: `Foo`, |
|
247 |
+ }, |
|
248 |
+ |
|
249 |
+ "basic whitespace": { |
|
250 |
+ Headers: http.Header{ |
|
251 |
+ WWW_AUTHENTICATE: []string{` basic realm = "Foo\" Bar" `}, |
|
252 |
+ }, |
|
253 |
+ ExpectedBasic: true, |
|
254 |
+ ExpectedRealm: `Foo\" Bar`, |
|
255 |
+ }, |
|
256 |
+ |
|
257 |
+ "basic trailing comma": { |
|
258 |
+ Headers: http.Header{ |
|
259 |
+ WWW_AUTHENTICATE: []string{`basic realm="Foo", otherparam="value"`}, |
|
260 |
+ }, |
|
261 |
+ ExpectedBasic: true, |
|
262 |
+ ExpectedRealm: `Foo`, |
|
263 |
+ }, |
|
264 |
+ |
|
265 |
+ "realm containing quotes": { |
|
266 |
+ Headers: http.Header{ |
|
267 |
+ WWW_AUTHENTICATE: []string{`basic realm="F\"oo", otherparam="value"`}, |
|
268 |
+ }, |
|
269 |
+ ExpectedBasic: true, |
|
270 |
+ ExpectedRealm: `F\"oo`, |
|
271 |
+ }, |
|
272 |
+ |
|
273 |
+ "realm containing comma": { |
|
274 |
+ Headers: http.Header{ |
|
275 |
+ WWW_AUTHENTICATE: []string{`basic realm="Foo, bar", otherparam="value"`}, |
|
276 |
+ }, |
|
277 |
+ ExpectedBasic: true, |
|
278 |
+ ExpectedRealm: `Foo, bar`, |
|
279 |
+ }, |
|
280 |
+ |
|
281 |
+ // TODO: additional forms to support |
|
282 |
+ // Basic param="value", realm="myrealm" |
|
283 |
+ // Digest, Basic param="value", realm="myrealm" |
|
284 |
+ } |
|
285 |
+ |
|
286 |
+ for k, tc := range testCases { |
|
287 |
+ isBasic, realm := basicRealm(tc.Headers) |
|
288 |
+ if isBasic != tc.ExpectedBasic { |
|
289 |
+ t.Errorf("%s: Expected isBasicChallenge=%v, got %v", k, tc.ExpectedBasic, isBasic) |
|
290 |
+ } |
|
291 |
+ if realm != tc.ExpectedRealm { |
|
292 |
+ t.Errorf("%s: Expected realm=%q, got %q", k, tc.ExpectedRealm, realm) |
|
293 |
+ } |
|
294 |
+ } |
|
295 |
+} |
0 | 296 |
deleted file mode 100644 |
... | ... |
@@ -1,87 +0,0 @@ |
1 |
-package tokencmd |
|
2 |
- |
|
3 |
-import ( |
|
4 |
- "fmt" |
|
5 |
- "io" |
|
6 |
- "net/http" |
|
7 |
- "regexp" |
|
8 |
- "strings" |
|
9 |
- |
|
10 |
- kclient "k8s.io/kubernetes/pkg/client" |
|
11 |
- |
|
12 |
- "github.com/openshift/origin/pkg/cmd/util" |
|
13 |
-) |
|
14 |
- |
|
15 |
-// CSRFTokenHeader is a marker header that indicates we are not a browser that got tricked into requesting basic auth |
|
16 |
-// Corresponds to the header expected by basic-auth challenging authenticators |
|
17 |
-const CSRFTokenHeader = "X-CSRF-Token" |
|
18 |
- |
|
19 |
-// challengingClient conforms the kclient.HTTPClient interface. It introspects responses for auth challenges and |
|
20 |
-// tries to response to those challenges in order to get a token back. |
|
21 |
-type challengingClient struct { |
|
22 |
- delegate *http.Client |
|
23 |
- reader io.Reader |
|
24 |
- defaultUsername string |
|
25 |
- defaultPassword string |
|
26 |
-} |
|
27 |
- |
|
28 |
-const basicAuthPattern = `[\s]*Basic[\s]*realm="([\w]+)"` |
|
29 |
- |
|
30 |
-var basicAuthRegex = regexp.MustCompile(basicAuthPattern) |
|
31 |
- |
|
32 |
-// Do watches for unauthorized challenges. If we know to respond, we respond to the challenge |
|
33 |
-func (client *challengingClient) Do(req *http.Request) (*http.Response, error) { |
|
34 |
- // Set custom header required by server to avoid CSRF attacks on browsers using basic auth |
|
35 |
- if req.Header == nil { |
|
36 |
- req.Header = http.Header{} |
|
37 |
- } |
|
38 |
- req.Header.Set(CSRFTokenHeader, "1") |
|
39 |
- |
|
40 |
- resp, err := client.delegate.Do(req) |
|
41 |
- if err != nil { |
|
42 |
- return nil, err |
|
43 |
- } |
|
44 |
- |
|
45 |
- if resp.StatusCode == http.StatusUnauthorized { |
|
46 |
- if wantsBasicAuth, realm := isBasicAuthChallenge(resp); wantsBasicAuth { |
|
47 |
- username := client.defaultUsername |
|
48 |
- password := client.defaultPassword |
|
49 |
- |
|
50 |
- missingUsername := len(username) == 0 |
|
51 |
- missingPassword := len(password) == 0 |
|
52 |
- |
|
53 |
- url := *req.URL |
|
54 |
- url.Path, url.RawQuery, url.Fragment = "", "", "" |
|
55 |
- |
|
56 |
- if (missingUsername || missingPassword) && client.reader != nil { |
|
57 |
- fmt.Printf("Authentication required for %s (%s)\n", &url, realm) |
|
58 |
- if missingUsername { |
|
59 |
- username = util.PromptForString(client.reader, "Username: ") |
|
60 |
- } |
|
61 |
- if missingPassword { |
|
62 |
- password = util.PromptForPasswordString(client.reader, "Password: ") |
|
63 |
- } |
|
64 |
- } |
|
65 |
- |
|
66 |
- if len(username) > 0 || len(password) > 0 { |
|
67 |
- client.delegate.Transport = kclient.NewBasicAuthRoundTripper(username, password, client.delegate.Transport) |
|
68 |
- return client.delegate.Do(resp.Request) |
|
69 |
- } |
|
70 |
- } |
|
71 |
- } |
|
72 |
- return resp, err |
|
73 |
-} |
|
74 |
- |
|
75 |
-func isBasicAuthChallenge(resp *http.Response) (bool, string) { |
|
76 |
- for currHeader, headerValue := range resp.Header { |
|
77 |
- if strings.EqualFold(currHeader, "WWW-Authenticate") { |
|
78 |
- for _, currAuthorizeHeader := range headerValue { |
|
79 |
- if matches := basicAuthRegex.FindAllStringSubmatch(currAuthorizeHeader, 1); matches != nil { |
|
80 |
- return true, matches[0][1] |
|
81 |
- } |
|
82 |
- } |
|
83 |
- } |
|
84 |
- } |
|
85 |
- |
|
86 |
- return false, "" |
|
87 |
-} |
... | ... |
@@ -1,121 +1,154 @@ |
1 | 1 |
package tokencmd |
2 | 2 |
|
3 | 3 |
import ( |
4 |
- "bytes" |
|
5 |
- "encoding/json" |
|
6 | 4 |
"errors" |
7 | 5 |
"fmt" |
8 | 6 |
"io" |
7 |
+ "io/ioutil" |
|
9 | 8 |
"net/http" |
10 | 9 |
"net/url" |
10 |
+ "strings" |
|
11 | 11 |
|
12 |
- "github.com/RangelReale/osincli" |
|
13 |
- "github.com/golang/glog" |
|
14 |
- |
|
12 |
+ "k8s.io/kubernetes/pkg/api" |
|
13 |
+ apierrs "k8s.io/kubernetes/pkg/api/errors" |
|
15 | 14 |
kclient "k8s.io/kubernetes/pkg/client" |
16 |
- |
|
17 |
- "github.com/openshift/origin/pkg/client" |
|
18 |
- "github.com/openshift/origin/pkg/oauth/server/osinserver" |
|
15 |
+ "k8s.io/kubernetes/pkg/util" |
|
19 | 16 |
) |
20 | 17 |
|
18 |
+// CSRFTokenHeader is a marker header that indicates we are not a browser that got tricked into requesting basic auth |
|
19 |
+// Corresponds to the header expected by basic-auth challenging authenticators |
|
20 |
+const CSRFTokenHeader = "X-CSRF-Token" |
|
21 |
+ |
|
21 | 22 |
// RequestToken uses the cmd arguments to locate an openshift oauth server and attempts to authenticate |
22 | 23 |
// it returns the access token if it gets one. An error if it does not |
23 | 24 |
func RequestToken(clientCfg *kclient.Config, reader io.Reader, defaultUsername string, defaultPassword string) (string, error) { |
24 |
- tokenGetter := &tokenGetterInfo{} |
|
25 |
- |
|
26 |
- osClient, err := client.New(clientCfg) |
|
27 |
- if err != nil { |
|
28 |
- return "", err |
|
25 |
+ challengeHandler := &BasicChallengeHandler{ |
|
26 |
+ Host: clientCfg.Host, |
|
27 |
+ Reader: reader, |
|
28 |
+ Username: defaultUsername, |
|
29 |
+ Password: defaultPassword, |
|
29 | 30 |
} |
30 | 31 |
|
31 |
- // get the transport, so that we can use it to build our own client that wraps it |
|
32 |
- // our client understands certain challenges and can respond to them |
|
33 |
- clientTransport, err := kclient.TransportFor(clientCfg) |
|
32 |
+ rt, err := kclient.TransportFor(clientCfg) |
|
34 | 33 |
if err != nil { |
35 | 34 |
return "", err |
36 | 35 |
} |
37 | 36 |
|
38 |
- httpClient := &http.Client{ |
|
39 |
- Transport: clientTransport, |
|
40 |
- CheckRedirect: tokenGetter.checkRedirect, |
|
41 |
- } |
|
42 |
- |
|
43 |
- osClient.Client = &challengingClient{httpClient, reader, defaultUsername, defaultPassword} |
|
37 |
+ // requestURL holds the current URL to make requests to. This can change if the server responds with a redirect |
|
38 |
+ requestURL := clientCfg.Host + "/oauth/authorize?response_type=token&client_id=openshift-challenging-client" |
|
39 |
+ // requestHeaders holds additional headers to add to the request. This can be changed by challengeHandlers |
|
40 |
+ requestHeaders := http.Header{} |
|
41 |
+ // requestedURLSet/requestedURLList hold the URLs we have requested, to prevent redirect loops. Gets reset when a challenge is handled. |
|
42 |
+ requestedURLSet := util.NewStringSet() |
|
43 |
+ requestedURLList := []string{} |
|
44 |
+ |
|
45 |
+ for { |
|
46 |
+ // Make the request |
|
47 |
+ resp, err := request(rt, requestURL, requestHeaders) |
|
48 |
+ if err != nil { |
|
49 |
+ return "", err |
|
50 |
+ } |
|
51 |
+ defer resp.Body.Close() |
|
52 |
+ |
|
53 |
+ if resp.StatusCode == http.StatusUnauthorized { |
|
54 |
+ if resp.Header.Get("WWW-Authenticate") != "" { |
|
55 |
+ if !challengeHandler.CanHandle(resp.Header) { |
|
56 |
+ return "", apierrs.NewUnauthorized("unhandled challenge") |
|
57 |
+ } |
|
58 |
+ // Handle a challenge |
|
59 |
+ newRequestHeaders, shouldRetry, err := challengeHandler.HandleChallenge(resp.Header) |
|
60 |
+ if err != nil { |
|
61 |
+ return "", err |
|
62 |
+ } |
|
63 |
+ if !shouldRetry { |
|
64 |
+ return "", apierrs.NewUnauthorized("challenger chose not to retry the request") |
|
65 |
+ } |
|
66 |
+ |
|
67 |
+ // Reset request set/list. Since we're setting different headers, it is legitimate to request the same urls |
|
68 |
+ requestedURLSet = util.NewStringSet() |
|
69 |
+ requestedURLList = []string{} |
|
70 |
+ // Use the response to the challenge as the new headers |
|
71 |
+ requestHeaders = newRequestHeaders |
|
72 |
+ continue |
|
73 |
+ } |
|
44 | 74 |
|
45 |
- result := osClient.Get().AbsPath("/oauth", osinserver.AuthorizePath). |
|
46 |
- Param("response_type", "token"). |
|
47 |
- Param("client_id", "openshift-challenging-client"). |
|
48 |
- Do() |
|
49 |
- if err := result.Error(); err != nil && !isRedirectError(err) { |
|
50 |
- return "", err |
|
51 |
- } |
|
75 |
+ // Unauthorized with no challenge |
|
76 |
+ unauthorizedError := apierrs.NewUnauthorized("") |
|
77 |
+ // Attempt to read body content and include as an error detail |
|
78 |
+ if details, err := ioutil.ReadAll(resp.Body); err == nil && len(details) > 0 { |
|
79 |
+ unauthorizedError.(*apierrs.StatusError).ErrStatus.Details = &api.StatusDetails{ |
|
80 |
+ Causes: []api.StatusCause{ |
|
81 |
+ {Message: string(details)}, |
|
82 |
+ }, |
|
83 |
+ } |
|
84 |
+ } |
|
52 | 85 |
|
53 |
- if len(tokenGetter.accessToken) == 0 { |
|
54 |
- r, _ := result.Raw() |
|
55 |
- if description, ok := rawOAuthJSONErrorDescription(r); ok { |
|
56 |
- return "", fmt.Errorf("cannot retrieve a token: %s", description) |
|
86 |
+ return "", unauthorizedError |
|
57 | 87 |
} |
58 |
- glog.V(4).Infof("A request token could not be created, server returned: %s", string(r)) |
|
59 |
- return "", fmt.Errorf("the server did not return a token (possible server error)") |
|
60 |
- } |
|
61 | 88 |
|
62 |
- return tokenGetter.accessToken, nil |
|
63 |
-} |
|
89 |
+ if resp.StatusCode == http.StatusFound { |
|
90 |
+ redirectURL := resp.Header.Get("Location") |
|
64 | 91 |
|
65 |
-func rawOAuthJSONErrorDescription(data []byte) (string, bool) { |
|
66 |
- output := osincli.ResponseData{} |
|
67 |
- decoder := json.NewDecoder(bytes.NewBuffer(data)) |
|
68 |
- if err := decoder.Decode(&output); err != nil { |
|
69 |
- return "", false |
|
70 |
- } |
|
71 |
- if _, ok := output["error"]; !ok { |
|
72 |
- return "", false |
|
73 |
- } |
|
74 |
- desc, ok := output["error_description"] |
|
75 |
- if !ok { |
|
76 |
- return "", false |
|
77 |
- } |
|
78 |
- s, ok := desc.(string) |
|
79 |
- if !ok || len(s) == 0 { |
|
80 |
- return "", false |
|
92 |
+ // OAuth response case (access_token or error parameter) |
|
93 |
+ accessToken, err := oauthAuthorizeResult(redirectURL) |
|
94 |
+ if err != nil { |
|
95 |
+ return "", err |
|
96 |
+ } |
|
97 |
+ if len(accessToken) > 0 { |
|
98 |
+ return accessToken, err |
|
99 |
+ } |
|
100 |
+ |
|
101 |
+ // Non-OAuth response, just follow the URL |
|
102 |
+ // add to our list of redirects |
|
103 |
+ requestedURLList = append(requestedURLList, redirectURL) |
|
104 |
+ // detect loops |
|
105 |
+ if !requestedURLSet.Has(redirectURL) { |
|
106 |
+ requestedURLSet.Insert(redirectURL) |
|
107 |
+ requestURL = redirectURL |
|
108 |
+ continue |
|
109 |
+ } |
|
110 |
+ return "", apierrs.NewInternalError(fmt.Errorf("redirect loop: %s", strings.Join(requestedURLList, " -> "))) |
|
111 |
+ } |
|
112 |
+ |
|
113 |
+ // Unknown response |
|
114 |
+ return "", apierrs.NewInternalError(fmt.Errorf("unexpected response: %d", resp.StatusCode)) |
|
81 | 115 |
} |
82 |
- return s, true |
|
83 | 116 |
} |
84 | 117 |
|
85 |
-const accessTokenKey = "access_token" |
|
86 |
- |
|
87 |
-var errRedirectComplete = errors.New("found access token") |
|
118 |
+func oauthAuthorizeResult(location string) (string, error) { |
|
119 |
+ u, err := url.Parse(location) |
|
120 |
+ if err != nil { |
|
121 |
+ return "", err |
|
122 |
+ } |
|
88 | 123 |
|
89 |
-type tokenGetterInfo struct { |
|
90 |
- accessToken string |
|
91 |
-} |
|
124 |
+ if errorCode := u.Query().Get("error"); len(errorCode) > 0 { |
|
125 |
+ errorDescription := u.Query().Get("error_description") |
|
126 |
+ return "", errors.New(errorCode + " " + errorDescription) |
|
127 |
+ } |
|
92 | 128 |
|
93 |
-// checkRedirect watches the redirects to see if any contain the access_token anchor. It then stores the value of the access token for later retrieval |
|
94 |
-func (tokenGetter *tokenGetterInfo) checkRedirect(req *http.Request, via []*http.Request) error { |
|
95 |
- fragment := req.URL.Fragment |
|
96 |
- if values, err := url.ParseQuery(fragment); err == nil { |
|
97 |
- if v, ok := values[accessTokenKey]; ok { |
|
98 |
- if len(v) > 0 { |
|
99 |
- tokenGetter.accessToken = v[0] |
|
100 |
- } |
|
101 |
- return errRedirectComplete |
|
102 |
- } |
|
129 |
+ fragmentValues, err := url.ParseQuery(u.Fragment) |
|
130 |
+ if err != nil { |
|
131 |
+ return "", err |
|
103 | 132 |
} |
104 | 133 |
|
105 |
- if len(via) >= 10 { |
|
106 |
- return errors.New("stopped after 10 redirects") |
|
134 |
+ if accessToken := fragmentValues.Get("access_token"); len(accessToken) > 0 { |
|
135 |
+ return accessToken, nil |
|
107 | 136 |
} |
108 | 137 |
|
109 |
- return nil |
|
138 |
+ return "", nil |
|
110 | 139 |
} |
111 | 140 |
|
112 |
-func isRedirectError(err error) bool { |
|
113 |
- if err == errRedirectComplete { |
|
114 |
- return true |
|
141 |
+func request(rt http.RoundTripper, requestURL string, requestHeaders http.Header) (*http.Response, error) { |
|
142 |
+ // Build the request |
|
143 |
+ req, err := http.NewRequest("GET", requestURL, nil) |
|
144 |
+ if err != nil { |
|
145 |
+ return nil, err |
|
115 | 146 |
} |
116 |
- switch t := err.(type) { |
|
117 |
- case *url.Error: |
|
118 |
- return t.Err == errRedirectComplete |
|
147 |
+ for k, v := range requestHeaders { |
|
148 |
+ req.Header[k] = v |
|
119 | 149 |
} |
120 |
- return false |
|
150 |
+ req.Header.Set(CSRFTokenHeader, "1") |
|
151 |
+ |
|
152 |
+ // Make the request |
|
153 |
+ return rt.RoundTrip(req) |
|
121 | 154 |
} |
... | ... |
@@ -3,16 +3,22 @@ |
3 | 3 |
package integration |
4 | 4 |
|
5 | 5 |
import ( |
6 |
+ "bytes" |
|
7 |
+ "io" |
|
6 | 8 |
"io/ioutil" |
7 | 9 |
"net/http" |
10 |
+ "net/http/httptest" |
|
11 |
+ "net/url" |
|
8 | 12 |
"os" |
9 | 13 |
"regexp" |
10 | 14 |
"testing" |
11 | 15 |
|
12 | 16 |
kclient "k8s.io/kubernetes/pkg/client" |
17 |
+ clientcmdapi "k8s.io/kubernetes/pkg/client/clientcmd/api" |
|
13 | 18 |
"k8s.io/kubernetes/pkg/runtime" |
14 | 19 |
|
15 | 20 |
"github.com/openshift/origin/pkg/client" |
21 |
+ "github.com/openshift/origin/pkg/cmd/cli/cmd" |
|
16 | 22 |
configapi "github.com/openshift/origin/pkg/cmd/server/api" |
17 | 23 |
testutil "github.com/openshift/origin/test/util" |
18 | 24 |
) |
... | ... |
@@ -88,7 +94,17 @@ qLwYJxjzwYTLvLYPU5vHmdg8v5wIXh0TaRTDTdKViISGD09aiXSYzw== |
88 | 88 |
`) |
89 | 89 |
) |
90 | 90 |
|
91 |
+// TestOAuthRequestHeader checks the following scenarios: |
|
92 |
+// * request containing remote user header is ignored if it doesn't have client cert auth |
|
93 |
+// * request containing remote user header is honored if it has client cert auth |
|
94 |
+// * unauthenticated requests are redirected to an auth proxy |
|
95 |
+// * login command succeeds against a request-header identity provider via redirection to an auth proxy |
|
91 | 96 |
func TestOAuthRequestHeader(t *testing.T) { |
97 |
+ // Test data used by auth proxy |
|
98 |
+ users := map[string]string{ |
|
99 |
+ "myusername": "mypassword", |
|
100 |
+ } |
|
101 |
+ |
|
92 | 102 |
// Write cert we're going to use to verify OAuth requestheader requests |
93 | 103 |
caFile, err := ioutil.TempFile("", "test.crt") |
94 | 104 |
if err != nil { |
... | ... |
@@ -99,19 +115,63 @@ func TestOAuthRequestHeader(t *testing.T) { |
99 | 99 |
t.Fatalf("unexpected error: %v", err) |
100 | 100 |
} |
101 | 101 |
|
102 |
+ // Get master config |
|
102 | 103 |
masterOptions, err := testutil.DefaultMasterOptions() |
103 | 104 |
if err != nil { |
104 | 105 |
t.Fatalf("unexpected error: %v", err) |
105 | 106 |
} |
107 |
+ masterURL, _ := url.Parse(masterOptions.OAuthConfig.MasterPublicURL) |
|
108 |
+ |
|
109 |
+ // Set up an auth proxy |
|
110 |
+ var proxyTransport http.RoundTripper |
|
111 |
+ proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|
112 |
+ // Decide whether to challenge |
|
113 |
+ username, password, hasBasicAuth := r.BasicAuth() |
|
114 |
+ if correctPassword, hasUser := users[username]; !hasBasicAuth || !hasUser || password != correctPassword { |
|
115 |
+ w.Header().Set("WWW-Authenticate", "Basic realm=Protected Area") |
|
116 |
+ w.WriteHeader(401) |
|
117 |
+ return |
|
118 |
+ } |
|
119 |
+ |
|
120 |
+ // Swap the scheme and host to the master, keeping path and params the same |
|
121 |
+ proxyURL := r.URL |
|
122 |
+ proxyURL.Scheme = masterURL.Scheme |
|
123 |
+ proxyURL.Host = masterURL.Host |
|
124 |
+ |
|
125 |
+ // Build a request, copying the original method, body, and headers, overriding the remote user headers |
|
126 |
+ proxyRequest, _ := http.NewRequest(r.Method, proxyURL.String(), r.Body) |
|
127 |
+ proxyRequest.Header = r.Header |
|
128 |
+ proxyRequest.Header.Set("My-Remote-User", username) |
|
129 |
+ proxyRequest.Header.Set("SSO-User", "") |
|
130 |
+ |
|
131 |
+ // Round trip to the back end |
|
132 |
+ response, err := proxyTransport.RoundTrip(r) |
|
133 |
+ if err != nil { |
|
134 |
+ t.Fatalf("Unexpected error: %v", err) |
|
135 |
+ } |
|
136 |
+ defer response.Body.Close() |
|
137 |
+ |
|
138 |
+ // Copy response back to originator |
|
139 |
+ for k, v := range response.Header { |
|
140 |
+ w.Header()[k] = v |
|
141 |
+ } |
|
142 |
+ w.WriteHeader(response.StatusCode) |
|
143 |
+ if _, err := io.Copy(w, response.Body); err != nil { |
|
144 |
+ t.Fatalf("Unexpected error: %v", err) |
|
145 |
+ } |
|
146 |
+ })) |
|
147 |
+ defer proxyServer.Close() |
|
106 | 148 |
|
107 | 149 |
masterOptions.OAuthConfig.IdentityProviders[0] = configapi.IdentityProvider{ |
108 | 150 |
Name: "requestheader", |
109 |
- UseAsChallenger: false, |
|
110 |
- UseAsLogin: false, |
|
151 |
+ UseAsChallenger: true, |
|
152 |
+ UseAsLogin: true, |
|
111 | 153 |
Provider: runtime.EmbeddedObject{ |
112 | 154 |
Object: &configapi.RequestHeaderIdentityProvider{ |
113 |
- ClientCA: caFile.Name(), |
|
114 |
- Headers: []string{"My-Remote-User", "SSO-User"}, |
|
155 |
+ ChallengeURL: proxyServer.URL + "/oauth/authorize?${query}", |
|
156 |
+ LoginURL: "http://www.example.com/login?then=${url}", |
|
157 |
+ ClientCA: caFile.Name(), |
|
158 |
+ Headers: []string{"My-Remote-User", "SSO-User"}, |
|
115 | 159 |
}, |
116 | 160 |
}, |
117 | 161 |
} |
... | ... |
@@ -132,49 +192,53 @@ func TestOAuthRequestHeader(t *testing.T) { |
132 | 132 |
anonConfig.Host = clientConfig.Host |
133 | 133 |
anonConfig.CAFile = clientConfig.CAFile |
134 | 134 |
anonConfig.CAData = clientConfig.CAData |
135 |
+ anonTransport, err := kclient.TransportFor(&anonConfig) |
|
136 |
+ if err != nil { |
|
137 |
+ t.Fatalf("unexpected error: %v", err) |
|
138 |
+ } |
|
139 |
+ |
|
140 |
+ // Use the server and CA info, with cert info |
|
141 |
+ proxyConfig := anonConfig |
|
142 |
+ proxyConfig.CertData = clientCert |
|
143 |
+ proxyConfig.KeyData = clientKey |
|
144 |
+ proxyTransport, err = kclient.TransportFor(&proxyConfig) |
|
145 |
+ if err != nil { |
|
146 |
+ t.Fatalf("unexpected error: %v", err) |
|
147 |
+ } |
|
135 | 148 |
|
136 |
- // Build the authorize request with the My-Remote-User header |
|
149 |
+ // Build the authorize request, spoofing a remote user header |
|
137 | 150 |
authorizeURL := clientConfig.Host + "/oauth/authorize?client_id=openshift-challenging-client&response_type=token" |
138 | 151 |
req, err := http.NewRequest("GET", authorizeURL, nil) |
139 | 152 |
req.Header.Set("My-Remote-User", "myuser") |
140 | 153 |
|
141 | 154 |
// Make the request without cert auth |
142 |
- transport, err := kclient.TransportFor(&anonConfig) |
|
155 |
+ resp, err := anonTransport.RoundTrip(req) |
|
143 | 156 |
if err != nil { |
144 | 157 |
t.Fatalf("unexpected error: %v", err) |
145 | 158 |
} |
146 |
- resp, err := transport.RoundTrip(req) |
|
159 |
+ proxyRedirect, err := resp.Location() |
|
147 | 160 |
if err != nil { |
148 |
- t.Fatalf("unexpected error: %v", err) |
|
161 |
+ t.Fatalf("expected spoofed remote user header to get 302 redirect, got error: %v", err) |
|
149 | 162 |
} |
150 |
- redirect, err := resp.Location() |
|
151 |
- if err != nil { |
|
152 |
- t.Fatalf("expected 302 redirect, got error: %v", err) |
|
153 |
- } |
|
154 |
- if redirect.Query().Get("error") == "" { |
|
155 |
- t.Fatalf("expected unsuccessful token request, got redirected to %v", redirect.String()) |
|
163 |
+ if proxyRedirect.String() != proxyServer.URL+"/oauth/authorize?client_id=openshift-challenging-client&response_type=token" { |
|
164 |
+ t.Fatalf("expected redirect to proxy endpoint, got redirected to %v", proxyRedirect.String()) |
|
156 | 165 |
} |
157 | 166 |
|
158 |
- // Use the server and CA info, with cert info |
|
159 |
- authProxyConfig := anonConfig |
|
160 |
- authProxyConfig.CertData = clientCert |
|
161 |
- authProxyConfig.KeyData = clientKey |
|
167 |
+ // Request the redirected URL, which should cause the proxy to make the same request with cert auth |
|
168 |
+ req, err = http.NewRequest("GET", proxyRedirect.String(), nil) |
|
169 |
+ req.Header.Set("My-Remote-User", "myuser") |
|
170 |
+ req.SetBasicAuth("myusername", "mypassword") |
|
162 | 171 |
|
163 |
- // Make the request with cert info |
|
164 |
- transport, err = kclient.TransportFor(&authProxyConfig) |
|
165 |
- if err != nil { |
|
166 |
- t.Fatalf("unexpected error: %v", err) |
|
167 |
- } |
|
168 |
- resp, err = transport.RoundTrip(req) |
|
172 |
+ resp, err = proxyTransport.RoundTrip(req) |
|
169 | 173 |
if err != nil { |
170 | 174 |
t.Fatalf("unexpected error: %v", err) |
171 | 175 |
} |
172 |
- redirect, err = resp.Location() |
|
176 |
+ tokenRedirect, err := resp.Location() |
|
173 | 177 |
if err != nil { |
174 | 178 |
t.Fatalf("expected 302 redirect, got error: %v", err) |
175 | 179 |
} |
176 |
- if redirect.Query().Get("error") != "" { |
|
177 |
- t.Fatalf("expected successful token request, got error %v", redirect.String()) |
|
180 |
+ if tokenRedirect.Query().Get("error") != "" { |
|
181 |
+ t.Fatalf("expected successful token request, got error %v", tokenRedirect.String()) |
|
178 | 182 |
} |
179 | 183 |
|
180 | 184 |
// Extract the access_token |
... | ... |
@@ -182,11 +246,11 @@ func TestOAuthRequestHeader(t *testing.T) { |
182 | 182 |
// group #0 is everything. #1 #2 #3 |
183 | 183 |
accessTokenRedirectRegex := regexp.MustCompile(`(^|&)access_token=([^&]+)($|&)`) |
184 | 184 |
accessToken := "" |
185 |
- if matches := accessTokenRedirectRegex.FindStringSubmatch(redirect.Fragment); matches != nil { |
|
185 |
+ if matches := accessTokenRedirectRegex.FindStringSubmatch(tokenRedirect.Fragment); matches != nil { |
|
186 | 186 |
accessToken = matches[2] |
187 | 187 |
} |
188 | 188 |
if accessToken == "" { |
189 |
- t.Fatalf("Expected access token, got %s", redirect.String()) |
|
189 |
+ t.Fatalf("Expected access token, got %s", tokenRedirect.String()) |
|
190 | 190 |
} |
191 | 191 |
|
192 | 192 |
// Make sure we can use the token, and it represents who we expect |
... | ... |
@@ -200,7 +264,41 @@ func TestOAuthRequestHeader(t *testing.T) { |
200 | 200 |
if err != nil { |
201 | 201 |
t.Fatalf("Unexpected error: %v", err) |
202 | 202 |
} |
203 |
- if user.Name != "myuser" { |
|
204 |
- t.Fatalf("Expected myuser as the user, got %v", user) |
|
203 |
+ if user.Name != "myusername" { |
|
204 |
+ t.Fatalf("Expected myusername as the user, got %v", user) |
|
205 |
+ } |
|
206 |
+ |
|
207 |
+ // Get the master CA data for the login command |
|
208 |
+ masterCAFile := userConfig.CAFile |
|
209 |
+ if masterCAFile == "" { |
|
210 |
+ // Write master ca data |
|
211 |
+ tmpFile, err := ioutil.TempFile("", "ca.crt") |
|
212 |
+ if err != nil { |
|
213 |
+ t.Fatalf("unexpected error: %v", err) |
|
214 |
+ } |
|
215 |
+ defer os.Remove(tmpFile.Name()) |
|
216 |
+ if err := ioutil.WriteFile(tmpFile.Name(), userConfig.CAData, os.FileMode(0600)); err != nil { |
|
217 |
+ t.Fatalf("unexpected error: %v", err) |
|
218 |
+ } |
|
219 |
+ masterCAFile = tmpFile.Name() |
|
220 |
+ } |
|
221 |
+ |
|
222 |
+ // Attempt a login using a redirecting auth proxy |
|
223 |
+ loginOutput := &bytes.Buffer{} |
|
224 |
+ loginOptions := &cmd.LoginOptions{ |
|
225 |
+ Server: anonConfig.Host, |
|
226 |
+ CAFile: masterCAFile, |
|
227 |
+ StartingKubeConfig: &clientcmdapi.Config{}, |
|
228 |
+ Reader: bytes.NewBufferString("myusername\nmypassword\n"), |
|
229 |
+ Out: loginOutput, |
|
230 |
+ } |
|
231 |
+ if err := loginOptions.GatherInfo(); err != nil { |
|
232 |
+ t.Fatalf("Error trying to determine server info: %v\n%v", err, loginOutput.String()) |
|
233 |
+ } |
|
234 |
+ if loginOptions.Username != "myusername" { |
|
235 |
+ t.Fatalf("Unexpected user after authentication: %#v", loginOptions) |
|
236 |
+ } |
|
237 |
+ if len(loginOptions.Config.BearerToken) == 0 { |
|
238 |
+ t.Fatalf("Expected token after authentication: %#v", loginOptions.Config) |
|
205 | 239 |
} |
206 | 240 |
} |