-
-
Notifications
You must be signed in to change notification settings - Fork 13
/
Startup.cs
378 lines (327 loc) · 16.3 KB
/
Startup.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
366
367
368
369
370
371
372
373
374
375
376
377
378
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using BackendFramework.Contexts;
using BackendFramework.Helper;
using BackendFramework.Interfaces;
using BackendFramework.Models;
using BackendFramework.Repositories;
using BackendFramework.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using static System.Text.Encoding;
namespace BackendFramework
{
[ExcludeFromCodeCoverage]
public class Startup
{
private const string LocalhostCorsPolicy = "LocalhostCorsPolicy";
private readonly ILogger<Startup> _logger;
private IConfiguration Configuration { get; }
public Startup(ILogger<Startup> logger, IConfiguration configuration)
{
_logger = logger;
Configuration = configuration;
}
public class Settings
{
public const int DefaultPasswordResetExpireTime = 15;
public string ConnectionString { get; set; }
public string CombineDatabase { get; set; }
public string? SmtpServer { get; set; }
public int? SmtpPort { get; set; }
public string? SmtpUsername { get; set; }
public string? SmtpPassword { get; set; }
public string? SmtpAddress { get; set; }
public string? SmtpFrom { get; set; }
public int PassResetExpireTime { get; set; }
public Settings()
{
ConnectionString = "";
CombineDatabase = "";
PassResetExpireTime = DefaultPasswordResetExpireTime;
}
}
[Serializable]
private class EnvironmentNotConfiguredException : Exception { }
private string? CheckedEnvironmentVariable(string name, string? defaultValue, string error = "")
{
var contents = Environment.GetEnvironmentVariable(name);
if (contents is not null)
{
return contents;
}
_logger.LogError("Environment variable: {Name} is not defined. {Error}", name, error);
return defaultValue;
}
/// <summary> Determine if executing within a container (e.g. Docker). </summary>
private static bool IsInContainer()
{
return Environment.GetEnvironmentVariable("COMBINE_IS_IN_CONTAINER") is not null;
}
[Serializable]
private class AdminUserCreationException : Exception { }
/// <summary> This method gets called by the runtime. Use this method to add services for dependency injection.
/// </summary>
public void ConfigureServices(IServiceCollection services)
{
// Only add CORS rules if running outside of Docker/NGINX environment. Rules are not needed in a
// true reverse proxy setup.
if (!IsInContainer())
{
services.AddCors(options =>
{
options.AddPolicy(LocalhostCorsPolicy,
builder => builder
.AllowAnyHeader()
.AllowAnyMethod()
// Add URL for React CLI using during development.
.WithOrigins("http://localhost:3000")
.AllowCredentials());
});
}
// Configure JWT Authentication
const string secretKeyEnvName = "COMBINE_JWT_SECRET_KEY";
var secretKey = Environment.GetEnvironmentVariable(secretKeyEnvName);
// The JWT key size must be at least 256 bits long.
const int minKeyLength = 256 / 8;
if (secretKey is null || secretKey.Length < minKeyLength)
{
_logger.LogError("Must set {EnvName} environment variable to string of length {MinLength} or longer.",
secretKeyEnvName, minKeyLength);
throw new EnvironmentNotConfiguredException();
}
var key = ASCII.GetBytes(secretKey);
services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateIssuer = false,
ValidateAudience = false
};
});
services.AddControllersWithViews()
// Required so that integer enum's can be passed in JSON as their descriptive string names, rather
// than by opaque integer values. This makes the OpenAPI schema much more expressive for
// integer enums. https://stackoverflow.com/a/55541764
.AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
services.AddSignalR();
// Configure Swashbuckle OpenAPI generation.
// https://docs.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle
services.AddSwaggerGen();
services.Configure<Settings>(
options =>
{
var connectionStringKey = IsInContainer() ? "ContainerConnectionString" : "ConnectionString";
options.ConnectionString = Configuration[$"MongoDB:{connectionStringKey}"];
options.CombineDatabase = Configuration["MongoDB:CombineDatabase"];
const string emailServiceFailureMessage = "Email services will not work.";
options.SmtpServer = CheckedEnvironmentVariable(
"COMBINE_SMTP_SERVER",
null,
emailServiceFailureMessage);
options.SmtpPort = int.Parse(CheckedEnvironmentVariable(
"COMBINE_SMTP_PORT",
IEmailContext.InvalidPort.ToString(),
emailServiceFailureMessage)!);
options.SmtpUsername = CheckedEnvironmentVariable(
"COMBINE_SMTP_USERNAME",
null,
emailServiceFailureMessage);
options.SmtpPassword = CheckedEnvironmentVariable(
"COMBINE_SMTP_PASSWORD",
null,
emailServiceFailureMessage);
options.SmtpAddress = CheckedEnvironmentVariable(
"COMBINE_SMTP_ADDRESS",
null,
emailServiceFailureMessage);
options.SmtpFrom = CheckedEnvironmentVariable(
"COMBINE_SMTP_FROM",
null,
emailServiceFailureMessage);
options.PassResetExpireTime = int.Parse(CheckedEnvironmentVariable(
"COMBINE_PASSWORD_RESET_EXPIRE_TIME",
Settings.DefaultPasswordResetExpireTime.ToString(),
$"Using default value: {Settings.DefaultPasswordResetExpireTime}")!);
});
// Register concrete types for dependency injection
// Banner types
services.AddTransient<IBannerContext, BannerContext>();
services.AddTransient<IBannerRepository, BannerRepository>();
// Email types
services.AddTransient<IEmailContext, EmailContext>();
services.AddTransient<IEmailService, EmailService>();
services.AddTransient<IInviteService, InviteService>();
// Lift Service - Singleton to avoid initializing the Sldr multiple times,
// also to avoid leaking LanguageTag data
services.AddSingleton<ILiftService, LiftService>();
// Merge types
services.AddTransient<IMergeBlacklistContext, MergeBlacklistContext>();
services.AddTransient<IMergeGraylistContext, MergeGraylistContext>();
services.AddTransient<IMergeBlacklistRepository, MergeBlacklistRepository>();
services.AddTransient<IMergeGraylistRepository, MergeGraylistRepository>();
services.AddTransient<IMergeService, MergeService>();
// Password Reset types
services.AddTransient<IPasswordResetContext, PasswordResetContext>();
services.AddTransient<IPasswordResetService, PasswordResetService>();
// Permission types
services.AddTransient<IPermissionService, PermissionService>();
// Project types
services.AddTransient<IProjectContext, ProjectContext>();
services.AddTransient<IProjectRepository, ProjectRepository>();
// Semantic Domain types
services.AddSingleton<ISemanticDomainContext, SemanticDomainContext>();
services.AddSingleton<ISemanticDomainRepository, SemanticDomainRepository>();
// Speaker types
services.AddTransient<ISpeakerContext, SpeakerContext>();
services.AddTransient<ISpeakerRepository, SpeakerRepository>();
// Statistics types
services.AddSingleton<IStatisticsService, StatisticsService>();
// User types
services.AddTransient<IUserContext, UserContext>();
services.AddTransient<IUserRepository, UserRepository>();
// User Edit types
services.AddTransient<IUserEditContext, UserEditContext>();
services.AddTransient<IUserEditRepository, UserEditRepository>();
services.AddTransient<IUserEditService, UserEditService>();
// User Role types
services.AddTransient<IUserRoleContext, UserRoleContext>();
services.AddTransient<IUserRoleRepository, UserRoleRepository>();
// Word types (includes Frontier types)
services.AddTransient<IWordContext, WordContext>();
services.AddTransient<IWordRepository, WordRepository>();
services.AddTransient<IWordService, WordService>();
}
/// <summary> This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
/// </summary>
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApplicationLifetime appLifetime)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// The default HSTS value is 30 days. You may want to change this for production scenarios,
// see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseRouting();
// Apply CORS policy to all requests.
app.UseCors(LocalhostCorsPolicy);
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapDefaultControllerRoute();
endpoints.MapHub<CombineHub>("/hub");
});
// Configure OpenAPI (Formerly Swagger) schema generation
const string openApiRoutePrefix = "openapi";
app.UseSwagger(c =>
{
c.RouteTemplate = $"/{openApiRoutePrefix}/{{documentName}}/openapi.{{json|yaml}}";
});
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint($"/{openApiRoutePrefix}/v1/openapi.json", "Combine API V1");
c.RoutePrefix = openApiRoutePrefix;
});
// If an admin user has been created via the command line, treat that as a single action and shut the
// server down so the calling script knows it's been completed successfully or unsuccessfully.
var userRepo = app.ApplicationServices.GetService<IUserRepository>();
if (userRepo is not null && CreateAdminUser(userRepo))
{
_logger.LogInformation("Stopping application");
appLifetime.StopApplication();
}
}
/// <summary>
/// Create a new user with administrator privileges or change the password of an existing user and grant
/// administrator privileges.
/// </summary>
/// <returns> Whether the application should be stopped. </returns>
/// <exception cref="EnvironmentNotConfiguredException">
/// If required environment variables are not set.
/// </exception>
/// <exception cref="AdminUserCreationException">
/// If the requested admin user could not be created or updated.
/// </exception>
private bool CreateAdminUser(IUserRepository userRepo)
{
const string createAdminUsernameArg = "create-admin-username";
const string createAdminPasswordEnv = "COMBINE_ADMIN_PASSWORD";
const string createAdminEmailEnv = "COMBINE_ADMIN_EMAIL";
var username = Configuration.GetValue<string>(createAdminUsernameArg);
if (username is null)
{
_logger.LogInformation("No admin user name provided, skipped admin creation");
return false;
}
var password = Environment.GetEnvironmentVariable(createAdminPasswordEnv);
if (password is null)
{
_logger.LogError($"Must set {createAdminPasswordEnv} environment variable " +
$"when using {createAdminUsernameArg} command line option.");
throw new EnvironmentNotConfiguredException();
}
var adminEmail = Environment.GetEnvironmentVariable(createAdminEmailEnv);
if (adminEmail is null)
{
_logger.LogError($"Must set {createAdminEmailEnv} environment variable " +
$"when using {createAdminUsernameArg} command line option.");
throw new EnvironmentNotConfiguredException();
}
var existingUser = userRepo.GetAllUsers().Result.Find(x => x.Username == username);
if (existingUser is not null)
{
_logger.LogInformation(
"User {User} already exists. Updating password and granting admin permissions.", username);
if (userRepo.ChangePassword(existingUser.Id, password).Result == ResultOfUpdate.NotFound)
{
_logger.LogError("Failed to find user {User}.", username);
throw new AdminUserCreationException();
}
existingUser.IsAdmin = true;
if (userRepo.Update(existingUser.Id, existingUser, true).Result == ResultOfUpdate.NotFound)
{
_logger.LogError("Failed to find user {User}.", username);
throw new AdminUserCreationException();
}
return true;
}
_logger.LogInformation("Creating admin user: {User} ({Email}).", username, adminEmail);
var user = new User { Username = username, Password = password, Email = adminEmail, IsAdmin = true };
var returnedUser = userRepo.Create(user).Result;
if (returnedUser is null)
{
_logger.LogError("Failed to create admin user {User} ({Email}).", username, adminEmail);
throw new AdminUserCreationException();
}
return true;
}
}
}