Skip to content

Commit b5b9fb3

Browse files
authored
Merge pull request abpframework#10857 from abpframework/abpio/device
Add device login flow to CLI.
2 parents 9decaab + 1d996e1 commit b5b9fb3

File tree

5 files changed

+170
-72
lines changed

5 files changed

+170
-72
lines changed

framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Auth/AuthService.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ public async Task LoginAsync(string userName, string password, string organizati
9292
File.WriteAllText(CliPaths.AccessToken, accessToken, Encoding.UTF8);
9393
}
9494

95+
public async Task DeviceLoginAsync()
96+
{
97+
var configuration = new IdentityClientConfiguration(
98+
CliUrls.AccountAbpIo,
99+
"role email abpio abpio_www abpio_commercial openid offline_access",
100+
"abp-cli",
101+
"1q2w3e*",
102+
OidcConstants.GrantTypes.DeviceCode
103+
);
104+
105+
var accessToken = await AuthenticationService.GetAccessTokenAsync(configuration);
106+
107+
File.WriteAllText(CliPaths.AccessToken, accessToken, Encoding.UTF8);
108+
}
109+
95110
public async Task LogoutAsync()
96111
{
97112
string accessToken = null;

framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/LoginCommand.cs

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -42,52 +42,70 @@ public LoginCommand(AuthService authService,
4242

4343
public async Task ExecuteAsync(CommandLineArgs commandLineArgs)
4444
{
45-
if (commandLineArgs.Target.IsNullOrEmpty())
45+
if (!commandLineArgs.Options.ContainsKey("device"))
4646
{
47-
throw new CliUsageException(
48-
"Username name is missing!" +
49-
Environment.NewLine + Environment.NewLine +
50-
GetUsageInfo()
51-
);
52-
}
53-
54-
var organization = commandLineArgs.Options.GetOrNull(Options.Organization.Short, Options.Organization.Long);
55-
56-
if (await HasMultipleOrganizationAndThisNotSpecified(commandLineArgs, organization))
57-
{
58-
return;
59-
}
60-
61-
var password = commandLineArgs.Options.GetOrNull(Options.Password.Short, Options.Password.Long);
62-
if (password == null)
63-
{
64-
Console.Write("Password: ");
65-
password = ConsoleHelper.ReadSecret();
66-
if (password.IsNullOrWhiteSpace())
47+
if (commandLineArgs.Target.IsNullOrEmpty())
6748
{
6849
throw new CliUsageException(
69-
"Password is missing!" +
50+
"Username name is missing!" +
7051
Environment.NewLine + Environment.NewLine +
7152
GetUsageInfo()
7253
);
7354
}
74-
}
7555

76-
try
77-
{
78-
await AuthService.LoginAsync(
79-
commandLineArgs.Target,
80-
password,
81-
organization
82-
);
56+
var organization = commandLineArgs.Options.GetOrNull(Options.Organization.Short, Options.Organization.Long);
57+
58+
if (await HasMultipleOrganizationAndThisNotSpecified(commandLineArgs, organization))
59+
{
60+
return;
61+
}
62+
63+
var password = commandLineArgs.Options.GetOrNull(Options.Password.Short, Options.Password.Long);
64+
if (password == null)
65+
{
66+
Console.Write("Password: ");
67+
password = ConsoleHelper.ReadSecret();
68+
if (password.IsNullOrWhiteSpace())
69+
{
70+
throw new CliUsageException(
71+
"Password is missing!" +
72+
Environment.NewLine + Environment.NewLine +
73+
GetUsageInfo()
74+
);
75+
}
76+
}
77+
78+
try
79+
{
80+
await AuthService.LoginAsync(
81+
commandLineArgs.Target,
82+
password,
83+
organization
84+
);
85+
}
86+
catch (Exception ex)
87+
{
88+
LogCliError(ex, commandLineArgs);
89+
return;
90+
}
91+
92+
Logger.LogInformation($"Successfully logged in as '{commandLineArgs.Target}'");
8393
}
84-
catch (Exception ex)
94+
else
8595
{
86-
LogCliError(ex, commandLineArgs);
87-
return;
88-
}
96+
try
97+
{
98+
await AuthService.DeviceLoginAsync();
99+
}
100+
catch (Exception ex)
101+
{
102+
LogCliError(ex, commandLineArgs);
103+
return;
104+
}
89105

90-
Logger.LogInformation($"Successfully logged in as '{commandLineArgs.Target}'");
106+
var loginInfo = await AuthService.GetLoginInfoAsync();
107+
Logger.LogInformation($"Successfully logged in as '{loginInfo.Username}'");
108+
}
91109
}
92110

93111
private async Task<bool> HasMultipleOrganizationAndThisNotSpecified(CommandLineArgs commandLineArgs, string organization)
@@ -178,6 +196,7 @@ public string GetUsageInfo()
178196
sb.AppendLine("Usage:");
179197
sb.AppendLine(" abp login <username>");
180198
sb.AppendLine(" abp login <username> -p <password>");
199+
sb.AppendLine(" abp login <username> --device");
181200
sb.AppendLine("");
182201
sb.AppendLine("Example:");
183202
sb.AppendLine("");

framework/src/Volo.Abp.Cli/Volo/Abp/Cli/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ private static async Task Main(string[] args)
1818
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
1919
.MinimumLevel.Override("Volo.Abp", LogEventLevel.Warning)
2020
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
21+
.MinimumLevel.Override("Volo.Abp.IdentityModel", LogEventLevel.Information)
2122
#if DEBUG
2223
.MinimumLevel.Override("Volo.Abp.Cli", LogEventLevel.Debug)
2324
#else

framework/src/Volo.Abp.IdentityModel/Volo/Abp/IdentityModel/IdentityModelAuthenticationService.cs

Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using Microsoft.Extensions.Logging.Abstractions;
66
using Microsoft.Extensions.Options;
77
using System;
8-
using System.Collections.Generic;
98
using System.Linq;
109
using System.Net.Http;
1110
using System.Net.Http.Headers;
@@ -114,53 +113,46 @@ protected virtual void SetAccessToken(HttpClient client, string accessToken)
114113
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
115114
}
116115

117-
protected virtual async Task<string> GetTokenEndpoint(IdentityClientConfiguration configuration)
116+
protected virtual async Task<IdentityModelDiscoveryDocumentCacheItem> GetDiscoveryResponse(IdentityClientConfiguration configuration)
118117
{
119-
//TODO: Can use (configuration.Authority + /connect/token) directly?
120-
121118
var tokenEndpointUrlCacheKey = CalculateDiscoveryDocumentCacheKey(configuration);
122119
var discoveryDocumentCacheItem = await DiscoveryDocumentCache.GetAsync(tokenEndpointUrlCacheKey);
123120
if (discoveryDocumentCacheItem == null)
124121
{
125-
var discoveryResponse = await GetDiscoveryResponse(configuration);
122+
DiscoveryDocumentResponse discoveryResponse;
123+
using (var httpClient = HttpClientFactory.CreateClient(HttpClientName))
124+
{
125+
var request = new DiscoveryDocumentRequest
126+
{
127+
Address = configuration.Authority,
128+
Policy =
129+
{
130+
RequireHttps = configuration.RequireHttps
131+
}
132+
};
133+
IdentityModelHttpRequestMessageOptions.ConfigureHttpRequestMessage?.Invoke(request);
134+
discoveryResponse = await httpClient.GetDiscoveryDocumentAsync(request);
135+
}
136+
126137
if (discoveryResponse.IsError)
127138
{
128139
throw new AbpException($"Could not retrieve the OpenId Connect discovery document! " +
129140
$"ErrorType: {discoveryResponse.ErrorType}. Error: {discoveryResponse.Error}");
130141
}
131142

132-
discoveryDocumentCacheItem = new IdentityModelDiscoveryDocumentCacheItem(discoveryResponse.TokenEndpoint);
143+
discoveryDocumentCacheItem = new IdentityModelDiscoveryDocumentCacheItem(discoveryResponse.TokenEndpoint, discoveryResponse.DeviceAuthorizationEndpoint);
133144
await DiscoveryDocumentCache.SetAsync(tokenEndpointUrlCacheKey, discoveryDocumentCacheItem,
134145
new DistributedCacheEntryOptions
135146
{
136147
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(configuration.CacheAbsoluteExpiration)
137148
});
138149
}
139150

140-
return discoveryDocumentCacheItem.TokenEndpoint;
141-
}
142-
143-
protected virtual async Task<DiscoveryDocumentResponse> GetDiscoveryResponse(IdentityClientConfiguration configuration)
144-
{
145-
using (var httpClient = HttpClientFactory.CreateClient(HttpClientName))
146-
{
147-
var request = new DiscoveryDocumentRequest
148-
{
149-
Address = configuration.Authority,
150-
Policy =
151-
{
152-
RequireHttps = configuration.RequireHttps
153-
}
154-
};
155-
IdentityModelHttpRequestMessageOptions.ConfigureHttpRequestMessage?.Invoke(request);
156-
return await httpClient.GetDiscoveryDocumentAsync(request);
157-
}
151+
return discoveryDocumentCacheItem;
158152
}
159153

160154
protected virtual async Task<TokenResponse> GetTokenResponse(IdentityClientConfiguration configuration)
161155
{
162-
var tokenEndpoint = await GetTokenEndpoint(configuration);
163-
164156
using (var httpClient = HttpClientFactory.CreateClient(HttpClientName))
165157
{
166158
AddHeaders(httpClient);
@@ -169,25 +161,30 @@ protected virtual async Task<TokenResponse> GetTokenResponse(IdentityClientConfi
169161
{
170162
case OidcConstants.GrantTypes.ClientCredentials:
171163
return await httpClient.RequestClientCredentialsTokenAsync(
172-
await CreateClientCredentialsTokenRequestAsync(tokenEndpoint, configuration),
164+
await CreateClientCredentialsTokenRequestAsync(configuration),
173165
CancellationTokenProvider.Token
174166
);
175167
case OidcConstants.GrantTypes.Password:
176168
return await httpClient.RequestPasswordTokenAsync(
177-
await CreatePasswordTokenRequestAsync(tokenEndpoint, configuration),
169+
await CreatePasswordTokenRequestAsync(configuration),
178170
CancellationTokenProvider.Token
179171
);
172+
173+
case OidcConstants.GrantTypes.DeviceCode:
174+
return await RequestDeviceAuthorizationAsync(httpClient, configuration);
175+
180176
default:
181177
throw new AbpException("Grant type was not implemented: " + configuration.GrantType);
182178
}
183179
}
184180
}
185181

186-
protected virtual Task<PasswordTokenRequest> CreatePasswordTokenRequestAsync(string tokenEndpoint, IdentityClientConfiguration configuration)
182+
protected virtual async Task<PasswordTokenRequest> CreatePasswordTokenRequestAsync(IdentityClientConfiguration configuration)
187183
{
184+
var discoveryResponse = await GetDiscoveryResponse(configuration);
188185
var request = new PasswordTokenRequest
189186
{
190-
Address = tokenEndpoint,
187+
Address = discoveryResponse.TokenEndpoint,
191188
Scope = configuration.Scope,
192189
ClientId = configuration.ClientId,
193190
ClientSecret = configuration.ClientSecret,
@@ -197,27 +194,90 @@ protected virtual Task<PasswordTokenRequest> CreatePasswordTokenRequestAsync(str
197194

198195
IdentityModelHttpRequestMessageOptions.ConfigureHttpRequestMessage?.Invoke(request);
199196

200-
AddParametersToRequestAsync(configuration, request);
197+
await AddParametersToRequestAsync(configuration, request);
201198

202-
return Task.FromResult(request);
199+
return request;
203200
}
204201

205-
protected virtual Task<ClientCredentialsTokenRequest> CreateClientCredentialsTokenRequestAsync(string tokenEndpoint, IdentityClientConfiguration configuration)
202+
protected virtual async Task<ClientCredentialsTokenRequest> CreateClientCredentialsTokenRequestAsync(IdentityClientConfiguration configuration)
206203
{
204+
var discoveryResponse = await GetDiscoveryResponse(configuration);
207205
var request = new ClientCredentialsTokenRequest
208206
{
209-
Address = tokenEndpoint,
207+
Address = discoveryResponse.TokenEndpoint,
210208
Scope = configuration.Scope,
211209
ClientId = configuration.ClientId,
212210
ClientSecret = configuration.ClientSecret
213211
};
214212
IdentityModelHttpRequestMessageOptions.ConfigureHttpRequestMessage?.Invoke(request);
215213

216-
AddParametersToRequestAsync(configuration, request);
214+
await AddParametersToRequestAsync(configuration, request);
215+
216+
return request;
217+
}
218+
219+
protected virtual async Task<TokenResponse> RequestDeviceAuthorizationAsync(HttpClient httpClient, IdentityClientConfiguration configuration)
220+
{
221+
var discoveryResponse = await GetDiscoveryResponse(configuration);
222+
var request = new DeviceAuthorizationRequest()
223+
{
224+
Address = discoveryResponse.DeviceAuthorizationEndpoint,
225+
Scope = configuration.Scope,
226+
ClientId = configuration.ClientId,
227+
ClientSecret = configuration.ClientSecret,
228+
};
229+
230+
IdentityModelHttpRequestMessageOptions.ConfigureHttpRequestMessage?.Invoke(request);
231+
232+
await AddParametersToRequestAsync(configuration, request);
233+
234+
var response = await httpClient.RequestDeviceAuthorizationAsync(request);
235+
if (response.IsError)
236+
{
237+
throw new AbpException(response.ErrorDescription);
238+
}
239+
240+
Logger.LogInformation($"First copy your one-time code: {response.UserCode}");
241+
Logger.LogInformation($"Open {response.VerificationUri} in your browser...");
242+
243+
for (var i = 0; i < ((response.ExpiresIn ?? 300) / response.Interval + 1); i++)
244+
{
245+
await Task.Delay(response.Interval * 1000);
246+
247+
var tokenResponse = await httpClient.RequestDeviceTokenAsync(new DeviceTokenRequest
248+
{
249+
Address = discoveryResponse.TokenEndpoint,
250+
ClientId = configuration.ClientId,
251+
ClientSecret = configuration.ClientSecret,
252+
DeviceCode = response.DeviceCode
253+
});
217254

218-
return Task.FromResult(request);
255+
if (tokenResponse.IsError)
256+
{
257+
switch (tokenResponse.Error)
258+
{
259+
case "slow_down":
260+
case "authorization_pending":
261+
break;
262+
263+
case "expired_token":
264+
throw new AbpException("This 'device_code' has expired. (expired_token)");
265+
266+
case "access_denied":
267+
throw new AbpException("User denies the request(access_denied)");
268+
}
269+
}
270+
271+
if (!tokenResponse.IsError)
272+
{
273+
return tokenResponse;
274+
}
275+
}
276+
277+
throw new AbpException("Timeout!");
219278
}
220279

280+
221281
protected virtual Task AddParametersToRequestAsync(IdentityClientConfiguration configuration, ProtocolRequest request)
222282
{
223283
foreach (var pair in configuration.Where(p => p.Key.StartsWith("[o]", StringComparison.OrdinalIgnoreCase)))

framework/src/Volo.Abp.IdentityModel/Volo/Abp/IdentityModel/IdentityModelDiscoveryDocumentCacheItem.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ public class IdentityModelDiscoveryDocumentCacheItem
99
{
1010
public string TokenEndpoint { get; set; }
1111

12+
public string DeviceAuthorizationEndpoint { get; set; }
13+
1214
public IdentityModelDiscoveryDocumentCacheItem()
1315
{
1416

1517
}
1618

17-
public IdentityModelDiscoveryDocumentCacheItem(string tokenEndpoint)
19+
public IdentityModelDiscoveryDocumentCacheItem(string tokenEndpoint, string deviceAuthorizationEndpoint)
1820
{
1921
TokenEndpoint = tokenEndpoint;
22+
DeviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
2023
}
2124

2225
public static string CalculateCacheKey(IdentityClientConfiguration configuration)

0 commit comments

Comments
 (0)