-
Notifications
You must be signed in to change notification settings - Fork 1.9k
/
Copy pathBitbucketHostProvider.cs
365 lines (295 loc) · 15.7 KB
/
BitbucketHostProvider.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Git.CredentialManager;
using Microsoft.Git.CredentialManager.Authentication.OAuth;
namespace Atlassian.Bitbucket
{
public class BitbucketHostProvider : IHostProvider
{
private readonly ICommandContext _context;
private readonly IBitbucketAuthentication _bitbucketAuth;
private readonly IBitbucketRestApi _bitbucketApi;
public BitbucketHostProvider(ICommandContext context)
: this(context, new BitbucketAuthentication(context), new BitbucketRestApi(context)) { }
public BitbucketHostProvider(ICommandContext context, IBitbucketAuthentication bitbucketAuth, IBitbucketRestApi bitbucketApi)
{
EnsureArgument.NotNull(context, nameof(context));
EnsureArgument.NotNull(bitbucketAuth, nameof(bitbucketAuth));
EnsureArgument.NotNull(bitbucketApi, nameof(bitbucketApi));
_context = context;
_bitbucketAuth = bitbucketAuth;
_bitbucketApi = bitbucketApi;
}
#region IHostProvider
public string Id => "bitbucket";
public string Name => "Bitbucket";
public IEnumerable<string> SupportedAuthorityIds => BitbucketAuthentication.AuthorityIds;
public bool IsSupported(InputArguments input)
{
if (input is null)
{
return false;
}
// Split port number and hostname from host input argument
if (!input.TryGetHostAndPort(out string hostName, out _))
{
return false;
}
// We do not support unencrypted HTTP communications to Bitbucket,
// but we report `true` here for HTTP so that we can show a helpful
// error message for the user in `GetCredentialAsync`.
return (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") ||
StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) &&
hostName.EndsWith(BitbucketConstants.BitbucketBaseUrlHost, StringComparison.OrdinalIgnoreCase);
}
public bool IsSupported(HttpResponseMessage response)
{
if (response is null)
{
return false;
}
// Identify Bitbucket on-prem instances from the HTTP response using the Atlassian specific header X-AREQUESTID
var supported = response.Headers.Contains("X-AREQUESTID");
_context.Trace.WriteLine($"Host is{(supported ? null : "n't")} supported as Bitbucket");
return supported;
}
public async Task<ICredential> GetCredentialAsync(InputArguments input)
{
// Compute the remote URI
Uri targetUri = input.GetRemoteUri();
// We should not allow unencrypted communication and should inform the user
if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")
&& IsBitbucketOrg(targetUri))
{
throw new Exception("Unencrypted HTTP is not supported for Bitbucket.org. Ensure the repository remote URL is using HTTPS.");
}
// Check for presence of refresh_token entry in credential store
string refreshTokenService = GetRefreshTokenServiceName(input);
AuthenticationModes authModes = GetSupportedAuthenticationModes(targetUri);
_context.Trace.WriteLine("Checking for refresh token...");
ICredential refreshToken = SupportsOAuth(authModes) ? _context.CredentialStore.Get(refreshTokenService, input.UserName) : null;
if (refreshToken is null)
{
// There is no refresh token either because this is a non-2FA enabled account (where OAuth is not
// required), or because we previously erased the RT.
// Check for the presence of a credential in the store
string credentialService = GetServiceName(input);
if (SupportsBasicAuth(authModes))
{
_context.Trace.WriteLine("Checking for credentials...");
ICredential credential = _context.CredentialStore.Get(credentialService, input.UserName);
if (credential is null)
{
// We don't have any credentials to use at all! Start with the assumption of no 2FA requirement
// and capture username and password via an interactive prompt.
credential = await _bitbucketAuth.GetBasicCredentialsAsync(targetUri, input.UserName);
if (credential is null)
{
throw new Exception("User cancelled authentication prompt.");
}
}
// Either we have an existing credential (user/pass OR some form of token [PAT or AT]),
// or we have a freshly captured user/pass. Regardless, we must check if these credentials
// pass and two-factor requirement on the account.
_context.Trace.WriteLine("Checking if two-factor requirements for stored credentials...");
bool requires2Fa = await RequiresTwoFactorAuthenticationAsync(credential, authModes);
if (!requires2Fa)
{
_context.Trace.WriteLine("Two-factor authentication not required");
// Return the valid credential
return credential;
}
}
if (SupportsOAuth(authModes))
{
_context.Trace.WriteLine("Two-factor authentication is required - prompting for auth via OAuth...");
// Show the 2FA/OAuth authentication required prompt
bool @continue = await _bitbucketAuth.ShowOAuthRequiredPromptAsync();
if (!@continue)
{
throw new Exception("User cancelled OAuth authentication.");
}
// Fall through to the start of the interactive OAuth authentication flow
}
}
else
{
// TODO: should we try and compute if the AT has expired and use it?
// This needs support from the credential store to record the expiry time!
// It's very likely that any access token expired between the last time we used/stored it.
// To ensure the AT is as 'fresh' as it can be, always first try to use the refresh token
// (which lives longer) to create a new AT (and possibly also a new RT).
try
{
return await GetOAuthCredentialsViaRefreshFlow(input, refreshTokenService, refreshToken);
}
catch (OAuth2Exception ex)
{
_context.Trace.WriteLine("Failed to refresh existing OAuth credential using refresh token");
_context.Trace.WriteException(ex);
// We failed to refresh the AT using the RT; log the refresh failure and fall through to restart
// the OAuth authentication flow
}
}
return await GetOAuthCredentialsInteractive(targetUri, refreshTokenService);
}
private async Task<ICredential> GetOAuthCredentialsViaRefreshFlow(InputArguments input, string refreshTokenService, ICredential refreshToken)
{
_context.Trace.WriteLine("Refreshing OAuth credentials using refresh token...");
OAuth2TokenResult refreshResult = await _bitbucketAuth.RefreshOAuthCredentialsAsync(refreshToken.Password);
// Resolve the username
_context.Trace.WriteLine("Resolving username for refreshed OAuth credential...");
string refreshUserName = await ResolveOAuthUserNameAsync(refreshResult.AccessToken);
_context.Trace.WriteLine($"Username for refreshed OAuth credential is '{refreshUserName}'");
// Store the refreshed RT
_context.Trace.WriteLine("Storing new refresh token...");
_context.CredentialStore.AddOrUpdate(refreshTokenService, input.UserName, refreshResult.RefreshToken);
// Return new access token
return new GitCredential(refreshUserName, refreshResult.AccessToken);
}
private async Task<ICredential> GetOAuthCredentialsInteractive(Uri targetUri, string refreshTokenService)
{
// We failed to use the refresh token either because it didn't exist, or because the refresh token is no
// longer valid. Either way we must now try authenticating using OAuth interactively.
// Start OAuth authentication flow
_context.Trace.WriteLine("Starting OAuth authentication flow...");
OAuth2TokenResult oauthResult = await _bitbucketAuth.CreateOAuthCredentialsAsync(targetUri);
// Resolve the username
_context.Trace.WriteLine("Resolving username for OAuth credential...");
string newUserName = await ResolveOAuthUserNameAsync(oauthResult.AccessToken);
_context.Trace.WriteLine($"Username for OAuth credential is '{newUserName}'");
// Store the new RT
_context.Trace.WriteLine("Storing new refresh token...");
_context.CredentialStore.AddOrUpdate(refreshTokenService, newUserName, oauthResult.RefreshToken);
_context.Trace.WriteLine("Refresh token was successfully stored.");
// Return the new AT as the credential
return new GitCredential(newUserName, oauthResult.AccessToken);
}
private static bool SupportsOAuth(AuthenticationModes authModes)
{
return (authModes & AuthenticationModes.OAuth) != 0;
}
private static bool SupportsBasicAuth(AuthenticationModes authModes)
{
return (authModes & AuthenticationModes.Basic) != 0;
}
public AuthenticationModes GetSupportedAuthenticationModes(Uri targetUri)
{
if(!IsBitbucketOrg(targetUri))
{
// Bitbucket Server/DC should use Basic only
return BitbucketConstants.ServerAuthenticationModes;
}
// Check for an explicit override for supported authentication modes
if (_context.Settings.TryGetSetting(
BitbucketConstants.EnvironmentVariables.AuthenticationModes,
Constants.GitConfiguration.Credential.SectionName, BitbucketConstants.GitConfiguration.Credential.AuthenticationModes,
out string authModesStr))
{
if (Enum.TryParse(authModesStr, true, out AuthenticationModes authModes) && authModes != AuthenticationModes.None)
{
_context.Trace.WriteLine($"Supported authentication modes override present: {authModes}");
return authModes;
}
else
{
_context.Trace.WriteLine($"Invalid value for supported authentication modes override setting: '{authModesStr}'");
}
}
// Bitbucket.org should use Basic, OAuth or manual PAT based authentication only
_context.Trace.WriteLine($"{targetUri} is bitbucket.org - authentication schemes: '{BitbucketConstants.DotOrgAuthenticationModes}'");
return BitbucketConstants.DotOrgAuthenticationModes;
}
public Task StoreCredentialAsync(InputArguments input)
{
// It doesn't matter if this is an OAuth access token, or the literal username & password
// because we store them the same way, against the same credential key in the store.
// The OAuth refresh token is already stored on the 'get' request.
string service = GetServiceName(input);
_context.Trace.WriteLine("Storing credential...");
_context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password);
_context.Trace.WriteLine("Credential was successfully stored.");
return Task.CompletedTask;
}
public Task EraseCredentialAsync(InputArguments input)
{
// Erase the stored credential (which may be either the literal username & password, or
// the OAuth access token). We don't need to erase the OAuth refresh token because on the
// next 'get' request, if the RT is bad we will erase and reacquire a new one at that point.
string service = GetServiceName(input);
_context.Trace.WriteLine("Erasing credential...");
if (_context.CredentialStore.Remove(service, input.UserName))
{
_context.Trace.WriteLine("Credential was successfully erased.");
}
else
{
_context.Trace.WriteLine("Credential was not erased.");
}
return Task.CompletedTask;
}
#endregion
#region Private Methods
private async Task<string> ResolveOAuthUserNameAsync(string accessToken)
{
RestApiResult<UserInfo> result = await _bitbucketApi.GetUserInformationAsync(null, accessToken, true);
if (result.Succeeded)
{
return result.Response.UserName;
}
throw new Exception($"Failed to resolve username. HTTP: {result.StatusCode}");
}
private async Task<bool> RequiresTwoFactorAuthenticationAsync(ICredential credentials, AuthenticationModes authModes)
{
if(!SupportsOAuth(authModes))
{
return false;
}
RestApiResult<UserInfo> result = await _bitbucketApi.GetUserInformationAsync(
credentials.Account, credentials.Password, false);
switch (result.StatusCode)
{
// 2FA may not be required
case HttpStatusCode.OK:
return result.Response.IsTwoFactorAuthenticationEnabled;
// 2FA is required
case HttpStatusCode.Forbidden:
return true;
// Incorrect credentials
case HttpStatusCode.Unauthorized:
throw new Exception("Invalid credentials");
default:
throw new Exception($"Unknown server response: {result.StatusCode}");
}
}
private static string GetServiceName(InputArguments input)
{
return input.GetRemoteUri(includeUser: false).AbsoluteUri.TrimEnd('/');
}
private static string GetRefreshTokenServiceName(InputArguments input)
{
Uri baseUri = input.GetRemoteUri(includeUser: false);
// The refresh token key never includes the path component.
// Instead we use the path component to specify this is the "refresh_token".
Uri uri = new UriBuilder(baseUri) {Path = "/refresh_token"}.Uri;
return uri.AbsoluteUri.TrimEnd('/');
}
public static bool IsBitbucketOrg(string targetUrl)
{
return Uri.TryCreate(targetUrl, UriKind.Absolute, out Uri uri) && IsBitbucketOrg(uri);
}
public static bool IsBitbucketOrg(Uri targetUri)
{
return StringComparer.OrdinalIgnoreCase.Equals(targetUri.Host, BitbucketConstants.BitbucketBaseUrlHost);
}
#endregion
public void Dispose()
{
_bitbucketApi.Dispose();
_bitbucketAuth.Dispose();
}
}
}