-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathJsConnectController.cs
216 lines (189 loc) · 8.78 KB
/
JsConnectController.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
using System;
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using jsConnect;
using jsConnect.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace jsConnect.Controllers
{
/// <summary>
/// Authentication endpoint implemented in accordance with http://docs.vanillaforums.com/help/sso/jsconnect/seamless/.
/// </summary>
[Route("[controller]")]
public class JsConnectController(IConfiguration configuration, ILogger<JsConnectController> logger, ILoggerFactory loggerFactory, HashAlgorithm hashAlgorithm) : AbstractControllerBase<JsConnectController>(configuration, logger, loggerFactory)
{
#region "Configuration"
/// <summary>
/// Client ID is used to identify a Vanilla instance and is public.
/// </summary>
public string ClientId => Configuration.GetValue("Vanilla:ClientID", string.Empty);
/// <summary>
/// Secret is like a site-password of a Vanilla instance and must be kept secret.
/// </summary>
public string ClientSecret => Configuration.GetValue("Vanilla:ClientSecret", string.Empty);
/// <summary>
/// For secure requests, Vanilla will send you the timestamp of its request.
/// If you are checking security than you want to make sure that the timestamp is not too old.
/// A recommended timeframe is about 5-30 minutes.
/// </summary>
public int TimestampValidFor => Configuration.GetValue("Vanilla:TimestampValidFor", 30 * 60);
/// <summary>
/// If false, whitespaces are removed from usernames.
/// </summary>
public bool AllowWhitespaceInUsername => Configuration.GetValue("Vanilla:AllowWhitespaceInUsername", false);
/// <summary>
/// If false, accented characters are replaced with non-accented chars.
/// </summary>
public bool AllowAccentsInUsername => Configuration.GetValue("Vanilla:AllowAccentsInUsername", false);
/// <summary>
/// If false, a unique username will be generated by finding and appending a suitable suffix.
/// </summary>
public bool AllowDuplicateUserNames => Configuration.GetValue("Vanilla:AllowDuplicateUserNames", false);
/// <summary>
/// Base Vanilla API URI. Example: https://forums.domain.tld/
/// </summary>
public string BaseUri => Configuration.GetValue("Vanilla:BaseUri", string.Empty);
#endregion
private HashAlgorithm HashAlgorithm { get; set; } = hashAlgorithm;
/// <summary>
/// Returns details of a currently signed-in user, if any.
/// </summary>
[HttpGet("[action]")]
[Produces("application/json")]
[ResponseCache(NoStore = true, Duration = 0)]
public async Task<ActionResult> AuthenticateAsync([FromQuery] string client_id, [FromQuery] string callback, [FromQuery] int? timestamp = null, [FromQuery] string signature = null)
{
JsConnectResponseModel jsConnectResult = new();
try
{
ValidateJsIncomingRequest(client_id, timestamp, signature, callback);
var user = HttpContext.User;
if (user != null && user.Identity.IsAuthenticated && timestamp.HasValue)
{
string uniqueId = user.FindFirst(ClaimTypes.NameIdentifier).Value;
string email = user.FindFirst(ClaimTypes.Email).Value;
string fullName = user.FindFirst(ClaimTypes.Name).Value;
// Sign-in user response
jsConnectResult.UniqueId = uniqueId;
jsConnectResult.Name = await GetVanillaUsername(email, fullName);
jsConnectResult.Email = user.FindFirst(ClaimTypes.Email).Value;
jsConnectResult.PhotoUrl = user.FindFirst("AvatarUrl")?.Value;
jsConnectResult.Roles = user.FindFirst("Roles")?.Value;
}
else
{
// No user response
jsConnectResult.UniqueId = string.Empty;
jsConnectResult.Name = string.Empty;
jsConnectResult.Email = string.Empty;
jsConnectResult.PhotoUrl = string.Empty;
}
jsConnectResult.ClientId = ClientId;
jsConnectResult.Signature = Hash(jsConnectResult.QueryString + ClientSecret);
return new ContentResult
{
Content = jsConnectResult.ToJsonp(callback),
ContentType = "application/javascript",
StatusCode = (int)HttpStatusCode.OK
};
}
catch (Exception ex)
{
// Error response
if (ex is JsConnectException exception)
{
jsConnectResult.Error = exception.Error;
}
jsConnectResult.Message = ex.Message;
Logger.LogError(new EventId(ex.HResult), ex, ex.Message);
return new JsonResult(jsConnectResult);
}
}
private async Task<string> GetVanillaUsername(string email, string fullName)
{
var logger = LoggerFactory?.CreateLogger<VanillaApiClient>();
string resultingUserName = fullName;
using (var vanillaClient = new VanillaApiClient(BaseUri, logger))
{
// Try to get user
var vanillaUser = await vanillaClient.GetUser(email: email);
if (vanillaUser != null)
{
// Existing user (don't change username)
resultingUserName = vanillaUser.Profile.Name;
}
else
{
// New user (generate a new username based on settings)
if (!AllowWhitespaceInUsername)
{
resultingUserName = Regex.Replace(resultingUserName, @"\s+", "");
}
if (!AllowAccentsInUsername)
{
resultingUserName = resultingUserName.RemoveAccents();
}
if (!AllowDuplicateUserNames)
{
resultingUserName = await vanillaClient.GetUniqueUserName(resultingUserName);
}
}
}
return resultingUserName;
}
/// <summary>
/// Validates request parameters.
/// </summary>
private void ValidateJsIncomingRequest(string clientId, int? timestamp, string timestampHash, string callback)
{
if (string.IsNullOrEmpty(callback))
{
throw new JsConnectException(JsConnectException.ERROR_INVALID_REQUEST, $"The {nameof(callback)} parameter is missing.");
}
if (string.IsNullOrEmpty(clientId))
{
throw new JsConnectException(JsConnectException.ERROR_INVALID_REQUEST, "The client_id parameter is missing.");
}
else if (clientId != ClientId)
{
throw new JsConnectException(JsConnectException.ERROR_INVALID_CLIENT, $"Unknown client {clientId}.");
}
if (timestamp.HasValue)
{
if (Math.Abs(DateTime.UtcNow.Timestamp() - timestamp.Value) > TimestampValidFor)
{
throw new JsConnectException(JsConnectException.ERROR_INVALID_REQUEST, "The timestamp is expired.");
}
if (!IsTimestampValid(timestamp, timestampHash))
{
throw new JsConnectException(JsConnectException.ERROR_INVALID_REQUEST, "The signature is invalid.");
}
}
}
/// <summary>
/// Validates timestamp by creating it's signed hash and comparing it to the given hash.
/// </summary>
/// <param name="timestamp">Timestamp to validate</param>
/// <param name="timestampHash">Hash to validate the timestamp against</param>
private bool IsTimestampValid(int? timestamp, string timestampHash)
{
string clientHash = Hash(timestamp + ClientSecret);
return clientHash == timestampHash;
}
/// <summary>
/// Creates hash of the given content.
/// </summary>
/// <param name="content">Content to hash</param>
private string Hash(string content)
{
byte[] textBytes = Encoding.UTF8.GetBytes(content);
return HashAlgorithm.ComputeHash(textBytes).ToHexString();
}
}
}