Merge pull request #1152 from tgstation/1080-Oauth

OAuth Support
This commit is contained in:
Jordan Brown
2020-11-24 17:10:41 -05:00
committed by GitHub
70 changed files with 5264 additions and 252 deletions
+14
View File
@@ -189,6 +189,20 @@ We have a script to do this.
1. Run `dotnet ef migrations add SL<NameOfYourMigration> --context SqliteDatabaseContext`.
1. Follow the above steps.
## Adding OAuth Providers
OAuth providers are hardcoded but it is fairly easy to add new ones. The flow doesn't need to be strict OAuth either (r.e. /tg/ forums). Follow the following steps:
1. Add the name to the [Tgstation.Server.Api.Models.OAuthProviders](../src/Tgstation.Server.Api/Models/OAuthProviders.cs) enum (Also necessitates a minor HTTP API version bump).
1. Create an implementation of [IOAuthValidator](../src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs).
- Most providers can simply override the [GenericOAuthValidator](../src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs).
1. Construct the implementation in the [OAuthProviders](../src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs) class.
1. Add a null entry to the default [appsettings.json](../src/Tgstation.Server.Host/appsettings.json).
1. Update the main [README.md](../README.md) to indicate the new provider.
1. Update the [API documentation](../docs/API.dox) to indicate the new provider.
TGS should now be able to accept authentication response tokens from your provider.
### Important Note About the \[Required\] Attribute.
We use this attribute to ensure EFCore generated tables are not nullable for specific properties. They are valid to be null in API communication. Do not use this attribute expecting the model validator to prevent null data in API request.
+11 -4
View File
@@ -120,6 +120,14 @@ Create an `appsettings.Production.json` file next to `appsettings.json`. This wi
- `ControlPanel:AllowedOrigins`: Set the Access-Control-Allow-Origin headers to this list of origins for all responses (also enables all headers and methods). This is overridden by `ControlPanel:AllowAnyOrigin`
- `Security:<Provider Name>OAuth`: Sets the OAuth client ID and secret for a given `<Provider Name>`. The currently supported providers are `GitHub`, `Discord`, and `TGForums`. Setting these fields to `null` disables logins with the provider, but does not stop users from associating their accounts using the API. Sample Entry:
```json
"GitHubOAuth":{
"ClientId": "... (Note for `TGForums`, this is the redirect_uri used)",
"ClientSecret": "..."
}
```
### Database Configuration
If using a MariaDB/MySQL server, our client library [recommends you set 'utf8mb4' as your default charset](https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql#1-recommended-server-charset) disregard at your own risk.
@@ -372,10 +380,9 @@ TGS 4 can self update without stopping your DreamDaemon servers. Any V4 release
Here are tools for interacting with the TGS 4 web API
- [tgstation-server-control-panel]: Official client and included with the server (WIP). A react web app for using tgstation-server.
- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Official client. A cross platform GUI for using tgstation-server
- [Tgstation.Server.Client](https://www.nuget.org/packages/Tgstation.Server.Client): A nuget .NET Standard 2.0 TAP based library for communicating with tgstation-server
- [Tgstation.Server.Api](https://www.nuget.org/packages/Tgstation.Server.Api): A nuget .NET Standard 2.0 library containing API definitions for tgstation-server
- [Postman](https://www.getpostman.com/): This repository contains [TGS.postman_collection.json](tools/TGS.postman_collection.json) which is used during development for testing. Contains example requests for all endpoints but takes some knowledge to use (Note that the pre-request script is configured to login the default admin user for every request)
- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Official client. A cross platform GUI for using tgstation-server. Feature complete but lacks OAuth login options.
- [Tgstation.Server.Client](https://www.nuget.org/packages/Tgstation.Server.Client): A nuget .NET Standard 2.0 TAP based library for communicating with tgstation-server. Feature complete.
- [Tgstation.Server.Api](https://www.nuget.org/packages/Tgstation.Server.Api): A nuget .NET Standard 2.0 library containing API definitions for tgstation-server. Feature complete.
Contact project maintainers to get your client added to this list
+4 -4
View File
@@ -3,10 +3,10 @@
<!-- Integration tests will ensure they match across the board -->
<Import Project="ControlPanelVersion.props" />
<PropertyGroup>
<TgsCoreVersion>4.6.0</TgsCoreVersion>
<TgsConfigVersion>2.1.1</TgsConfigVersion>
<TgsApiVersion>7.4.0</TgsApiVersion>
<TgsClientVersion>8.4.0</TgsClientVersion>
<TgsCoreVersion>4.7.0</TgsCoreVersion>
<TgsConfigVersion>2.2.0</TgsConfigVersion>
<TgsApiVersion>8.0.0</TgsApiVersion>
<TgsClientVersion>9.0.0</TgsClientVersion>
<TgsDmapiVersion>5.2.8</TgsDmapiVersion>
<TgsHostWatchdogVersion>1.1.0</TgsHostWatchdogVersion>
<TgsContainerScriptVersion>1.1.1</TgsContainerScriptVersion>
+46 -18
View File
@@ -77,6 +77,24 @@ TGS will only every return the response codes listed here
- 501: Not implemented. Functionality not available in the current server version
- 503: Service unavailable. The server is either starting up or shutting down and isn't ready to respond to requests. You can try again soon and a response/lack thereof will indicate which of the two events it was
@section api_ver Server Information
The versions of the TGS host can be retireved with this request.
Note: This endpoint does not require authentication
GET "/" => @ref Tgstation.Server.Api.Models.ServerInformation
The Version model fields are based on the C# one and looks like this:
@code{.json}
{
"version": "<major>.<minor>.<patch>.<revision>"
}
@endcode
Other fields may be present in the Version model but should be ignored. See a description of these version numbers <a href="https://github.com/tgstation/tgstation-server/blob/master/.github/CONTRIBUTING.md#versioning">here</a>.
@section api_auth Authentication
Every request made to TGS requires authentication. It is provided in the form of the Authorization header.
@@ -87,7 +105,7 @@ POST "/" => @ref Tgstation.Server.Api.Models.Token
Headers:
- Authorization:basic `<Base64 encoded credentials in the form username:password>`
- Authorization:Basic `<Base64 encoded credentials in the form username:password>`
If the provided credentials are valid and your user account is enabled you will recieve a @ref Tgstation.Server.Api.Models.Token object
@code{.json}
@@ -100,10 +118,36 @@ If your account is disabled, you will recieve a 403 response.
You may recognize the bearer value as a <a href="https://jwt.io">Json Web Token</a>. This is a secure representation of your identity to the server. It expires after a set period of time or until your password changes. It must be present for requests made to all other APIs. To do so add the following header to your other requests
- Authorization:bearer `<your bearer token>`
- Authorization:Bearer `<your bearer token>`
Continue to use this token until you begin to recieve 401 responses from the API. Then repeat the process to get a new one if your credentials are still valid
@subsection api_auth_o OAuth 2.0
TGS4 supports OAuth 2.0 with select providers for authentication.
The flow for this is as follows:
- Retrieve the @ref api_ver to find out available OAuth providers and their respective client IDs.
- Send the user to the Authorization Request endpoint for the provider using the client ID from above. See https://tools.ietf.org/html/rfc6749#section-4.1.1. DO NOT specify a redirect URI, this should be configured in the provider.
- Retrieve the authorization response code after successfully completing the authorize step above.
- Perform the following request:
POST "/" => @ref Tgstation.Server.Api.Models.Token
Headers:
- Authorization:OAuth `OAuth Authorization Response Code`
- OAuthProvider:<Provider Name>
You will be granted a bearer token as in basic auth. This will have an extended expiration to avoid repeating the entire process.
@subsubsection api_auth_o_providers Supported Providers
- ID: 0, Name: GitHub, Documentation: https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/
- ID: 1, Name: Discord, Documentation: https://discord.com/developers/docs/topics/oauth2
- ID: 2, Name: TGForums, Documentation: https://tgstation13.org/phpBB/viewtopic.php?f=45&t=9922
@section api_perms Permissions
Almost all actions available require some level of user permissions. Which are divided into two categories:
@@ -150,22 +194,6 @@ I DELETE "/InstanceUser/{UserId}" => OK
Users with the permission to modify @ref Tgstation.Server.Api.Models.Instance objects can also gain user editing rights for any Instance. See @ref api_instance
@section api_ver Version
The versions of the TGS host can be retireved with this request
GET "/" => @ref Tgstation.Server.Api.Models.ServerInformation
The Version model fields are based on the C# one and looks like this:
@code{.json}
{
"version": "<major>.<minor>.<patch>.<revision>"
}
@endcode
Other fields may be present in the Version model but should be ignored. See a description of these version numbers <a href="https://github.com/tgstation/tgstation-server/blob/master/.github/CONTRIBUTING.md#versioning">here</a>.
@section api_admin Server-wide Administrative Actions
You can retrieve server update information with this
+61 -21
View File
@@ -8,6 +8,7 @@ using System.Net.Http.Headers;
using System.Net.Mime;
using System.Reflection;
using System.Text;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Api
{
@@ -17,24 +18,34 @@ namespace Tgstation.Server.Api
public sealed class ApiHeaders
{
/// <summary>
/// The <see cref="ApiVersion"/> header key
/// The <see cref="ApiVersion"/> header key.
/// </summary>
public const string ApiVersionHeader = "Api";
/// <summary>
/// The <see cref="InstanceId"/> header key
/// The <see cref="InstanceId"/> header key.
/// </summary>
public const string InstanceIdHeader = "Instance";
/// <summary>
/// The JWT authentication header scheme
/// The <see cref="OAuthProvider"/> header key.
/// </summary>
public const string JwtAuthenticationScheme = "bearer";
public const string OAuthProviderHeader = "OAuthProvider";
/// <summary>
/// The JWT authentication header scheme
/// </summary>
public const string BasicAuthenticationScheme = "basic";
public const string BearerAuthenticationScheme = "Bearer";
/// <summary>
/// The JWT authentication header scheme
/// </summary>
public const string BasicAuthenticationScheme = "Basic";
/// <summary>
/// The JWT authentication header scheme
/// </summary>
public const string OAuthAuthenticationScheme = "OAuth";
/// <summary>
/// The current <see cref="System.Reflection.AssemblyName"/>
@@ -47,7 +58,7 @@ namespace Tgstation.Server.Api
public static readonly Version Version = AssemblyName.Version.Semver();
/// <summary>
/// The instance <see cref="Models.EntityId.Id"/> being accessed
/// The instance <see cref="EntityId.Id"/> being accessed
/// </summary>
public long? InstanceId { get; set; }
@@ -82,9 +93,14 @@ namespace Tgstation.Server.Api
public string? Password { get; }
/// <summary>
/// If the header uses password or JWT authentication
/// The <see cref="Models.OAuthProvider"/> the <see cref="Token"/> is for, if any.
/// </summary>
public bool IsTokenAuthentication => Token != null;
public OAuthProvider? OAuthProvider { get; }
/// <summary>
/// If the header uses password or TGS JWT authentication.
/// </summary>
public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue;
/// <summary>
/// Checks if a given <paramref name="otherVersion"/> is compatible with our own
@@ -98,12 +114,15 @@ namespace Tgstation.Server.Api
/// </summary>
/// <param name="userAgent">The value of <see cref="UserAgent"/></param>
/// <param name="token">The value of <see cref="Token"/></param>
public ApiHeaders(ProductHeaderValue userAgent, string token) : this(userAgent, token, null, null)
/// <param name="oauthProvider">The value of <see cref="OAuthProvider"/>.</param>
public ApiHeaders(ProductHeaderValue userAgent, string token, OAuthProvider? oauthProvider = null) : this(userAgent, token, null, null)
{
if (userAgent == null)
throw new ArgumentNullException(nameof(userAgent));
if (token == null)
throw new ArgumentNullException(nameof(token));
OAuthProvider = oauthProvider;
}
/// <summary>
@@ -126,8 +145,10 @@ namespace Tgstation.Server.Api
/// Construct and validates <see cref="ApiHeaders"/> from a set of <paramref name="requestHeaders"/>
/// </summary>
/// <param name="requestHeaders">The <see cref="RequestHeaders"/> containing the <see cref="ApiHeaders"/></param>
/// <param name="ignoreMissingAuth">If a missing <see cref="HeaderNames.Authorization"/> should be ignored.</param>
/// <exception cref="HeadersException">Thrown if the <paramref name="requestHeaders"/> constitue invalid <see cref="ApiHeaders"/>.</exception>
public ApiHeaders(RequestHeaders requestHeaders)
#pragma warning disable CA1502
public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false)
{
if (requestHeaders == null)
throw new ArgumentNullException(nameof(requestHeaders));
@@ -164,7 +185,10 @@ namespace Tgstation.Server.Api
AddError(HeaderTypes.Api, $"Malformed {ApiVersionHeader} header!");
if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization))
AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!");
{
if (!ignoreMissingAuth)
AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!");
}
else
{
var auth = authorization.First();
@@ -187,11 +211,22 @@ namespace Tgstation.Server.Api
InstanceId = instanceId;
}
#pragma warning disable CA1308 // Normalize strings to uppercase
switch (scheme.ToLowerInvariant())
#pragma warning restore CA1308 // Normalize strings to uppercase
switch (scheme)
{
case JwtAuthenticationScheme:
case OAuthAuthenticationScheme:
if (requestHeaders.Headers.TryGetValue(OAuthProviderHeader, out StringValues oauthProviderValues))
{
var oauthProviderString = oauthProviderValues.First();
if (Enum.TryParse<OAuthProvider>(oauthProviderString, out var oauthProvider))
OAuthProvider = oauthProvider;
else
AddError(HeaderTypes.OAuthProvider, "Invalid OAuth provider!");
}
else
AddError(HeaderTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!");
goto case BearerAuthenticationScheme;
case BearerAuthenticationScheme:
Token = parameter;
break;
case BasicAuthenticationScheme:
@@ -203,12 +238,12 @@ namespace Tgstation.Server.Api
}
catch
{
throw new InvalidOperationException("Invalid basic Authorization header!");
throw new InvalidOperationException($"Invalid basic {HeaderNames.Authorization} header!");
}
var basicAuthSplits = joinedString.Split(new char[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
if (basicAuthSplits.Length < 2)
throw new InvalidOperationException("Invalid basic Authorization header!");
throw new InvalidOperationException($"Invalid basic {HeaderNames.Authorization} header!");
Username = basicAuthSplits.First();
Password = String.Concat(basicAuthSplits.Skip(1));
@@ -226,6 +261,7 @@ namespace Tgstation.Server.Api
ApiVersion = apiVersion!.Semver();
}
#pragma warning restore CA1502
/// <summary>
/// Construct <see cref="ApiHeaders"/>
@@ -253,7 +289,7 @@ namespace Tgstation.Server.Api
/// Set <see cref="HttpRequestHeaders"/> using the <see cref="ApiHeaders"/>. This initially clears <paramref name="headers"/>
/// </summary>
/// <param name="headers">The <see cref="HttpRequestHeaders"/> to set</param>
/// <param name="instanceId">The instance <see cref="Models.EntityId.Id"/> for the request</param>
/// <param name="instanceId">The instance <see cref="EntityId.Id"/> for the request</param>
public void SetRequestHeaders(HttpRequestHeaders headers, long? instanceId = null)
{
if (headers == null)
@@ -263,12 +299,16 @@ namespace Tgstation.Server.Api
headers.Clear();
headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
if (IsTokenAuthentication)
headers.Authorization = new AuthenticationHeaderValue(JwtAuthenticationScheme, Token);
else
if (!IsTokenAuthentication)
headers.Authorization = new AuthenticationHeaderValue(
BasicAuthenticationScheme,
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}")));
else
{
headers.Authorization = new AuthenticationHeaderValue(BearerAuthenticationScheme, Token);
if (OAuthProvider.HasValue)
headers.Add(OAuthProviderHeader, OAuthProvider.ToString());
}
headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent));
headers.Add(ApiVersionHeader, new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString());
+9 -4
View File
@@ -1,4 +1,4 @@
using System;
using System;
namespace Tgstation.Server.Api
{
@@ -24,13 +24,18 @@ namespace Tgstation.Server.Api
Accept = 2,
/// <summary>
/// Api header.
/// <see cref="ApiHeaders.ApiVersionHeader"/>.
/// </summary>
Api = 4,
/// <summary>
/// <see cref="Microsoft.Net.Http.Headers.HeaderNames.Authorization"/>
/// </summary>
Authorization = 8
Authorization = 8,
/// <summary>
/// <see cref="ApiHeaders.OAuthProviderHeader"/>.
/// </summary>
OAuthProvider = 16,
}
}
}
+15 -3
View File
@@ -64,10 +64,10 @@ namespace Tgstation.Server.Api.Models
CannotChangeServerSuite,
/// <summary>
/// A required GitHub API request failed.
/// A required remote API request failed.
/// </summary>
[Description("A required GitHub request returned an API error!")]
GitHubApiError,
[Description("A required remote API request returned an error!")]
RemoteApiError,
/// <summary>
/// A server update was requested while another was in progress.
@@ -582,5 +582,17 @@ namespace Tgstation.Server.Api.Models
/// </summary>
[Description("The requested port is either already in use by TGS or could not be allocated!")]
PortNotAvailable,
/// <summary>
/// Attempted to set <see cref="User.OAuthConnections"/> for the admin user.
/// </summary>
[Description("The admin user cannot use OAuth connections!")]
AdminUserCannotOAuth,
/// <summary>
/// Attempted to login with a disabled OAuth provider.
/// </summary>
[Description("The requested OAuth provider is disabled via configuration!")]
OAuthProviderDisabled,
}
}
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a valid OAuth connection.
/// </summary>
public class OAuthConnection
{
/// <summary>
/// The <see cref="OAuthProvider"/> of the <see cref="OAuthConnection"/>.
/// </summary>]
[EnumDataType(typeof(OAuthProvider))]
public OAuthProvider Provider { get; set; }
/// <summary>
/// The ID of the user in the <see cref="Provider"/>.
/// </summary>
[Required]
[StringLength(Limits.MaximumIndexableStringLength)]
public string? ExternalUserId { get; set; }
}
}
@@ -0,0 +1,23 @@
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// List of OAuth providers supported by TGS
/// </summary>
public enum OAuthProvider
{
/// <summary>
/// https://github.com
/// </summary>
GitHub,
/// <summary>
/// https://discord.com
/// </summary>
Discord,
/// <summary>
/// https://tgstation13.org
/// </summary>
TGForums,
}
}
@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
namespace Tgstation.Server.Api.Models
{
@@ -21,5 +22,10 @@ namespace Tgstation.Server.Api.Models
/// The DMAPI version of the host.
/// </summary>
public Version? DMApiVersion { get; set; }
/// <summary>
/// Map of <see cref="OAuthProvider"/> to the server's associated client IDs for them.
/// </summary>
public IDictionary<OAuthProvider, string>? OAuthProviderClientIds { get; set; }
}
}
+9 -2
View File
@@ -1,4 +1,6 @@
namespace Tgstation.Server.Api.Models
using System.Collections.Generic;
namespace Tgstation.Server.Api.Models
{
/// <inheritdoc />
public class User : Internal.User
@@ -17,5 +19,10 @@
/// The <see cref="User"/> who created this <see cref="User"/>
/// </summary>
public Internal.User? CreatedBy { get; set; }
/// <summary>
/// List of <see cref="OAuthConnection"/>s associated with the <see cref="User"/>.
/// </summary>
public ICollection<OAuthConnection>? OAuthConnections { get; set; }
}
}
}
@@ -42,5 +42,10 @@ namespace Tgstation.Server.Api.Rights
/// User can list and download <see cref="Models.LogFile"/>s.
/// </summary>
DownloadLogs = 32,
/// <summary>
/// User can modify their own <see cref="Models.User.OAuthConnections"/>.
/// </summary>
EditOwnOAuthConnections = 64,
}
}
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "3.1.7",
"version": "3.1.10",
"commands": [
"dotnet-ef"
]
@@ -0,0 +1,37 @@
using System;
namespace Tgstation.Server.Host.Configuration
{
/// <summary>
/// OAuth options.
/// </summary>
class OAuthConfiguration
{
/// <summary>
/// The client ID.
/// </summary>
public string ClientId { get; set; }
/// <summary>
/// The client secret.
/// </summary>
public string ClientSecret { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="OAuthConfiguration"/> <see langword="class"/>.
/// </summary>
public OAuthConfiguration() { }
/// <summary>
/// Initializes a new instance of the <see cref="OAuthConfiguration"/> <see langword="class"/>.
/// </summary>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> to copy settings from.</param>
public OAuthConfiguration(OAuthConfiguration oAuthConfiguration)
{
if (oAuthConfiguration == null)
throw new ArgumentNullException(nameof(oAuthConfiguration));
ClientId = oAuthConfiguration.ClientId;
ClientSecret = oAuthConfiguration.ClientSecret;
}
}
}
@@ -1,3 +1,6 @@
using System.Collections.Generic;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Host.Configuration
{
/// <summary>
@@ -15,6 +18,11 @@ namespace Tgstation.Server.Host.Configuration
/// </summary>
const uint DefaultTokenExpiryMinutes = 15;
/// <summary>
/// Default value of <see cref="OAuthTokenExpiryMinutes"/>.
/// </summary>
const uint DefaultOAuthTokenExpiryMinutes = 60 * 24; // 1 day
/// <summary>
/// Default value of <see cref="TokenClockSkewMinutes"/>.
/// </summary>
@@ -26,12 +34,12 @@ namespace Tgstation.Server.Host.Configuration
const uint DefaultTokenSigningKeyByteAmount = 256;
/// <summary>
/// Amount of minutes until generated <see cref="Api.Models.Token"/>s expire.
/// Amount of minutes until <see cref="Token"/>s generated from passwords expire.
/// </summary>
public uint TokenExpiryMinutes { get; set; } = DefaultTokenExpiryMinutes;
/// <summary>
/// Amount of minutes to skew the clock for <see cref="Api.Models.Token"/> validation.
/// Amount of minutes to skew the clock for <see cref="Token"/> validation.
/// </summary>
public uint TokenClockSkewMinutes { get; set; } = DefaultTokenClockSkewMinutes;
@@ -40,9 +48,19 @@ namespace Tgstation.Server.Host.Configuration
/// </summary>
public uint TokenSigningKeyByteCount { get; set; } = DefaultTokenSigningKeyByteAmount;
/// <summary>
/// Amount of minutes until <see cref="Token"/>s generated from OAuth logins expire.
/// </summary>
public uint OAuthTokenExpiryMinutes { get; set; } = DefaultOAuthTokenExpiryMinutes;
/// <summary>
/// A custom token signing key. Overrides <see cref="TokenSigningKeyByteCount"/>.
/// </summary>
public string CustomTokenSigningKeyBase64 { get; set; }
/// <summary>
/// OAuth provider settings.
/// </summary>
public IDictionary<OAuthProvider, OAuthConfiguration> OAuth { get; set; }
}
}
@@ -1,11 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Octokit;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
@@ -115,14 +113,6 @@ namespace Tgstation.Server.Host.Controllers
fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
}
ObjectResult RateLimit(RateLimitExceededException exception)
{
Logger.LogWarning(exception, "Exceeded GitHub rate limit!");
var secondsString = Math.Ceiling((exception.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture);
Response.Headers.Add("Retry-After", new StringValues(secondsString));
return StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessage(ErrorCode.GitHubApiRateLimit));
}
/// <summary>
/// Try to download and apply an update with a given <paramref name="newVersion"/>.
/// </summary>
@@ -150,7 +140,10 @@ namespace Tgstation.Server.Host.Controllers
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return StatusCode(HttpStatusCode.FailedDependency);
return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message
});
}
releases = releases.Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture));
@@ -244,7 +237,7 @@ namespace Tgstation.Server.Host.Controllers
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.GitHubApiError)
return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message
});
@@ -2,8 +2,11 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
using Octokit;
using Serilog.Context;
using System;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Mime;
@@ -60,7 +63,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="logger">The value of <see cref="Logger"/></param>
/// <param name="requireHeaders">The value of <see cref="requireHeaders"/></param>
public ApiController(
protected ApiController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
ILogger<ApiController> logger,
@@ -115,6 +118,22 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="HttpStatusCode.Created"/> <see cref="ObjectResult"/> with the given <paramref name="payload"/>.</returns>
protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
/// <summary>
/// 429 response for a given <paramref name="rateLimitException"/>.
/// </summary>
/// <param name="rateLimitException">The <see cref="RateLimitExceededException"/> that occurred.</param>
/// <returns>A <see cref="HttpStatusCode.TooManyRequests"/> <see cref="ObjectResult"/>.</returns>
protected ObjectResult RateLimit(RateLimitExceededException rateLimitException)
{
if (rateLimitException == null)
throw new ArgumentNullException(nameof(rateLimitException));
Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!");
var secondsString = Math.Ceiling((rateLimitException.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture);
Response.Headers.Add(HeaderNames.RetryAfter, secondsString);
return StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessage(ErrorCode.GitHubApiRateLimit));
}
/// <summary>
/// Performs validation a request.
/// </summary>
@@ -126,13 +145,14 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Response for missing/Invalid headers.
/// </summary>
/// <param name="ignoreMissingAuth">Whether or not errors due to missing <see cref="HeaderNames.Authorization"/> should be thrown.</param>
/// <returns>The appropriate <see cref="IActionResult"/>.</returns>
protected IActionResult HeadersIssue()
protected IActionResult HeadersIssue(bool ignoreMissingAuth)
{
HeadersException headersException;
try
{
var _ = new ApiHeaders(Request.GetTypedHeaders());
var _ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth);
throw new InvalidOperationException("Expected a header parse exception!");
}
catch (HeadersException ex)
@@ -196,7 +216,7 @@ namespace Tgstation.Server.Host.Controllers
{
if (requireHeaders)
{
await HeadersIssue()
await HeadersIssue(false)
.ExecuteResultAsync(context)
.ConfigureAwait(false);
return;
@@ -239,9 +259,19 @@ namespace Tgstation.Server.Host.Controllers
{
if (ApiHeaders != null)
Logger.LogDebug(
"Starting API Request: Version: {0}. User-Agent: {1}",
"Starting API request: Version: {0}. {1}: {2}",
ApiHeaders.ApiVersion.Semver(),
HeaderNames.UserAgent,
ApiHeaders.RawUserAgent);
else if (Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgents))
Logger.LogDebug(
"Starting unauthorized API request. {0}: {1}",
HeaderNames.UserAgent,
userAgents);
else
Logger.LogDebug(
"Starting unauthorized API request. No {0}!",
HeaderNames.UserAgent);
await base.OnActionExecutionAsync(context, next).ConfigureAwait(false);
}
}
@@ -41,9 +41,7 @@ namespace Tgstation.Server.Host.Controllers
{
var contentTypeProvider = new FileExtensionContentTypeProvider();
if (!contentTypeProvider.TryGetContentType(fileInfo.Name, out var contentType))
{
contentType = "application/octet-stream";
}
contentType = MediaTypeNames.Application.Octet;
return File(appRoute, contentType);
}
@@ -1,12 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Octokit;
using System;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
@@ -17,6 +20,7 @@ using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
using Tgstation.Server.Host.System;
using Wangkanai.Detection;
@@ -53,6 +57,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly IIdentityCache identityCache;
/// <summary>
/// The <see cref="IOAuthProviders"/> for the <see cref="HomeController"/>.
/// </summary>
readonly IOAuthProviders oAuthProviders;
/// <summary>
/// The <see cref="IBrowserResolver"/> for the <see cref="HomeController"/>
/// </summary>
@@ -78,6 +87,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/></param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/></param>
/// <param name="identityCache">The value of <see cref="identityCache"/></param>
/// <param name="oAuthProviders">The value of <see cref="oAuthProviders"/>.</param>
/// <param name="browserResolver">The value of <see cref="browserResolver"/></param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="controlPanelConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="controlPanelConfiguration"/></param>
@@ -90,6 +100,7 @@ namespace Tgstation.Server.Host.Controllers
ICryptographySuite cryptographySuite,
IAssemblyInformationProvider assemblyInformationProvider,
IIdentityCache identityCache,
IOAuthProviders oAuthProviders,
IBrowserResolver browserResolver,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<ControlPanelConfiguration> controlPanelConfigurationOptions,
@@ -106,6 +117,7 @@ namespace Tgstation.Server.Host.Controllers
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache));
this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders));
this.browserResolver = browserResolver;
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions));
@@ -114,36 +126,49 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Main page of the <see cref="Application"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>
/// The <see cref="ServerInformation"/> of the <see cref="Application"/> if a properly authenticated API request, the web control panel if on a browser and enabled, <see cref="UnauthorizedResult"/> otherwise.
/// A <see cref="Task{TResult}"/> resuting in the <see cref="JsonResult"/> containing <see cref="ServerInformation"/> of the <see cref="Application"/> if a properly authenticated API request, the web control panel if on a browser and enabled, <see cref="UnauthorizedResult"/> otherwise.
/// </returns>
/// <response code="200"><see cref="ServerInformation"/> retrieved successfully.</response>
[HttpGet]
[TgsAuthorize]
[AllowAnonymous]
[ProducesResponseType(typeof(ServerInformation), 200)]
public IActionResult Home()
public async Task<IActionResult> Home(CancellationToken cancellationToken)
{
if (AuthenticationContext != null)
return Json(new ServerInformation
{
Version = assemblyInformationProvider.Version,
ApiVersion = ApiHeaders.Version,
DMApiVersion = DMApiConstants.Version,
MinimumPasswordLength = generalConfiguration.MinimumPasswordLength,
InstanceLimit = generalConfiguration.InstanceLimit,
UserLimit = generalConfiguration.UserLimit,
ValidInstancePaths = generalConfiguration.ValidInstancePaths
});
// if we are using a browser and the control panel, soft redirect to the app page
if (controlPanelConfiguration.Enable && browserResolver.Browser.Type != BrowserType.Generic)
{
Logger.LogDebug("Unauthorized browser request (User-Agent: \"{0}\"), redirecting to control panel...", browserResolver.UserAgent);
return Redirect(Application.ControlPanelRoute);
return Redirect(Core.Application.ControlPanelRoute);
}
return ApiHeaders == null ? HeadersIssue() : Unauthorized();
// we only allow authorization header issues
if (ApiHeaders == null)
try
{
var headers = new ApiHeaders(Request.GetTypedHeaders(), true);
if (!headers.Compatible())
return StatusCode(
HttpStatusCode.UpgradeRequired,
new ErrorMessage(ErrorCode.ApiMismatch));
}
catch (HeadersException)
{
return HeadersIssue(true);
}
return Json(new ServerInformation
{
Version = assemblyInformationProvider.Version,
ApiVersion = ApiHeaders.Version,
DMApiVersion = DMApiConstants.Version,
MinimumPasswordLength = generalConfiguration.MinimumPasswordLength,
InstanceLimit = generalConfiguration.InstanceLimit,
UserLimit = generalConfiguration.UserLimit,
ValidInstancePaths = generalConfiguration.ValidInstancePaths,
OAuthProviderClientIds = await oAuthProviders.ClientIds(cancellationToken).ConfigureAwait(false)
});
}
/// <summary>
@@ -154,40 +179,77 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="200">User logged in and <see cref="Token"/> generated successfully.</response>
/// <response code="401">User authentication failed.</response>
/// <response code="403">User authenticated but is disabled by an administrator.</response>
/// <response code="429">OAuth authentication failed due to rate limiting.</response>
[HttpPost]
[ProducesResponseType(typeof(Token), 200)]
#pragma warning disable CA1506 // TODO: Decomplexify
[ProducesResponseType(typeof(ErrorMessage), 429)]
#pragma warning disable CA1506 // TODO: Decomplexify
public async Task<IActionResult> CreateToken(CancellationToken cancellationToken)
{
if (ApiHeaders == null)
{
Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues("basic realm=\"Create TGS4 bearer token\""));
return HeadersIssue();
return HeadersIssue(false);
}
if (ApiHeaders.IsTokenAuthentication)
return BadRequest(new ErrorMessage(ErrorCode.TokenWithToken));
ISystemIdentity systemIdentity;
try
{
// trust the system over the database because a user's name can change while still having the same SID
systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password, cancellationToken).ConfigureAwait(false);
}
catch (NotImplementedException)
{
systemIdentity = null;
}
var oAuthLogin = ApiHeaders.OAuthProvider.HasValue;
ISystemIdentity systemIdentity = null;
if (!oAuthLogin)
try
{
// trust the system over the database because a user's name can change while still having the same SID
systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password, cancellationToken).ConfigureAwait(false);
}
catch (NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
}
using (systemIdentity)
{
// Get the user from the database
IQueryable<Models.User> query = DatabaseContext.Users.AsQueryable();
string canonicalName = Models.User.CanonicalizeName(ApiHeaders.Username);
if (systemIdentity == null)
query = query.Where(x => x.CanonicalName == canonicalName);
if (oAuthLogin)
{
string externalUserId;
try
{
var validator = oAuthProviders
.GetValidator(ApiHeaders.OAuthProvider.Value);
if (validator == null)
return BadRequest(new ErrorMessage(ErrorCode.OAuthProviderDisabled));
externalUserId = await validator
.ValidateResponseCode(ApiHeaders.Token, cancellationToken)
.ConfigureAwait(false);
}
catch (RateLimitExceededException ex)
{
return RateLimit(ex);
}
if (externalUserId == null)
return Unauthorized();
query = query.Where(
x => x.OAuthConnections.Any(
y => y.Provider == ApiHeaders.OAuthProvider.Value
&& y.ExternalUserId == externalUserId));
}
else
query = query.Where(x => x.CanonicalName == canonicalName || x.SystemIdentifier == systemIdentity.Uid);
{
string canonicalName = Models.User.CanonicalizeName(ApiHeaders.Username);
if (systemIdentity == null)
query = query.Where(x => x.CanonicalName == canonicalName);
else
query = query.Where(x => x.CanonicalName == canonicalName || x.SystemIdentifier == systemIdentity.Uid);
}
var users = await query.Select(x => new Models.User
{
Id = x.Id,
@@ -213,32 +275,33 @@ namespace Tgstation.Server.Host.Controllers
var originalHash = user.PasswordHash;
var isDbUser = originalHash != null;
bool usingSystemIdentity = systemIdentity != null && !isDbUser;
if (!usingSystemIdentity)
{
// DB User password check and update
if (!cryptographySuite.CheckUserPassword(user, ApiHeaders.Password))
return Unauthorized();
if (user.PasswordHash != originalHash)
if (!oAuthLogin)
if (!usingSystemIdentity)
{
Logger.LogDebug("User ID {0}'s password hash needs a refresh, updating database.", user.Id);
var updatedUser = new Models.User
// DB User password check and update
if (!cryptographySuite.CheckUserPassword(user, ApiHeaders.Password))
return Unauthorized();
if (user.PasswordHash != originalHash)
{
Id = user.Id
};
DatabaseContext.Users.Attach(updatedUser);
updatedUser.PasswordHash = user.PasswordHash;
Logger.LogDebug("User ID {0}'s password hash needs a refresh, updating database.", user.Id);
var updatedUser = new Models.User
{
Id = user.Id
};
DatabaseContext.Users.Attach(updatedUser);
updatedUser.PasswordHash = user.PasswordHash;
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
}
}
else if (systemIdentity.Username != user.Name)
{
// System identity username change update
Logger.LogDebug("User ID {0}'s system identity needs a refresh, updating database.", user.Id);
DatabaseContext.Users.Attach(user);
user.Name = systemIdentity.Username;
user.CanonicalName = Models.User.CanonicalizeName(user.Name);
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
}
}
else if (systemIdentity.Username != user.Name)
{
// System identity username change update
Logger.LogDebug("User ID {0}'s system identity needs a refresh, updating database.", user.Id);
DatabaseContext.Users.Attach(user);
user.Name = systemIdentity.Username;
user.CanonicalName = Models.User.CanonicalizeName(user.Name);
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
}
// Now that the bookeeping is done, tell them to fuck off if necessary
if (!user.Enabled.Value)
@@ -247,7 +310,7 @@ namespace Tgstation.Server.Host.Controllers
return Forbid();
}
var token = await tokenFactory.CreateToken(user, cancellationToken).ConfigureAwait(false);
var token = await tokenFactory.CreateToken(user, oAuthLogin, cancellationToken).ConfigureAwait(false);
if (usingSystemIdentity)
{
// expire the identity slightly after the auth token in case of lag
@@ -262,6 +325,6 @@ namespace Tgstation.Server.Host.Controllers
return Json(token);
}
}
#pragma warning restore CA1506
#pragma warning restore CA1506
}
}
@@ -281,7 +281,9 @@ namespace Tgstation.Server.Host.Controllers
}
}
#pragma warning disable CA1508 // Avoid dead conditional code
if (earlyOut != null)
#pragma warning restore CA1508 // Avoid dead conditional code
return earlyOut;
// Last test, ensure it's in the list of valid paths
@@ -420,9 +422,9 @@ namespace Tgstation.Server.Host.Controllers
var moveJob = await InstanceQuery()
.SelectMany(x => x.Jobs).
#pragma warning disable CA1307 // Specify StringComparison
#pragma warning disable CA1310 // Specify StringComparison
Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix))
#pragma warning restore CA1307 // Specify StringComparison
#pragma warning restore CA1310 // Specify StringComparison
.Select(x => new Models.Job
{
Id = x.Id
@@ -614,9 +616,9 @@ namespace Tgstation.Server.Host.Controllers
var moveJobs = await GetBaseQuery()
.SelectMany(x => x.Jobs)
#pragma warning disable CA1307 // Specify StringComparison
#pragma warning disable CA1310 // Specify StringComparison
.Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix))
#pragma warning restore CA1307 // Specify StringComparison
#pragma warning restore CA1310 // Specify StringComparison
.Include(x => x.StartedBy).ThenInclude(x => x.CreatedBy)
.Include(x => x.Instance)
.ToListAsync(cancellationToken)
@@ -687,9 +689,9 @@ namespace Tgstation.Server.Host.Controllers
var moveJob = await QueryForUser()
.SelectMany(x => x.Jobs)
#pragma warning disable CA1307 // Specify StringComparison
#pragma warning disable CA1310 // Specify StringComparison
.Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix))
#pragma warning restore CA1307 // Specify StringComparison
#pragma warning restore CA1310 // Specify StringComparison
.Include(x => x.StartedBy).ThenInclude(x => x.CreatedBy)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
@@ -426,7 +426,9 @@ namespace Tgstation.Server.Host.Controllers
|| (model.UpdateFromOrigin == true && !userRights.HasFlag(RepositoryRights.UpdateBranch)))
return Forbid();
if (currentModel.AccessToken?.Length == 0 && currentModel.AccessUser?.Length == 0)
#pragma warning disable CA1508 // Avoid dead conditional code
if (model.AccessToken?.Length == 0 && model.AccessUser?.Length == 0)
#pragma warning restore CA1508 // Avoid dead conditional code
{
// setting an empty string clears everything
currentModel.AccessUser = null;
@@ -74,7 +74,9 @@ namespace Tgstation.Server.Host.Controllers
BadRequestObjectResult CheckValidName(UserUpdate model, bool newUser)
{
var userInvalidWithNullName = newUser && model.Name == null && model.SystemIdentifier == null;
#pragma warning disable CA1508 // https://github.com/dotnet/roslyn-analyzers/issues/3685
if (userInvalidWithNullName || (model.Name != null && String.IsNullOrWhiteSpace(model.Name)))
#pragma warning restore CA1508
return BadRequest(new ErrorMessage(ErrorCode.UserMissingName));
model.Name = model.Name?.Trim();
@@ -118,6 +120,9 @@ namespace Tgstation.Server.Host.Controllers
if (model == null)
throw new ArgumentNullException(nameof(model));
if (model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure));
if (!(model.Password == null ^ model.SystemIdentifier == null))
return BadRequest(new ErrorMessage(ErrorCode.UserMismatchPasswordSid));
@@ -132,17 +137,7 @@ namespace Tgstation.Server.Host.Controllers
if (fail != null)
return fail;
var dbUser = new Models.User
{
AdministrationRights = RightsHelper.Clamp(model.AdministrationRights ?? AdministrationRights.None),
CreatedAt = DateTimeOffset.Now,
CreatedBy = AuthenticationContext.User,
Enabled = model.Enabled ?? false,
InstanceManagerRights = RightsHelper.Clamp(model.InstanceManagerRights ?? InstanceManagerRights.None),
Name = model.Name,
SystemIdentifier = model.SystemIdentifier,
InstanceUsers = new List<Models.InstanceUser>()
};
var dbUser = CreateNewUserFromModel(model);
if (model.SystemIdentifier != null)
try
@@ -157,7 +152,7 @@ namespace Tgstation.Server.Host.Controllers
{
return RequiresPosixSystemIdentity();
}
else
else if (!(model.Password?.Length == 0 && model.OAuthConnections.Count != 0))
{
var result = TrySetPassword(dbUser, model.Password, true);
if (result != null)
@@ -170,6 +165,8 @@ namespace Tgstation.Server.Host.Controllers
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
Logger.LogInformation("Created new user {0} ({1})", dbUser.Name, dbUser.Id);
return Created(dbUser.ToApi(true));
}
@@ -182,7 +179,7 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="200"><see cref="Api.Models.User"/> updated successfully.</response>
/// <response code="404">Requested <see cref="Api.Models.Internal.User.Id"/> does not exist.</response>
[HttpPost]
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)]
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)]
[ProducesResponseType(typeof(Api.Models.User), 200)]
[ProducesResponseType(typeof(ErrorMessage), 404)]
#pragma warning disable CA1502 // TODO: Decomplexify
@@ -192,19 +189,22 @@ namespace Tgstation.Server.Host.Controllers
if (model == null)
throw new ArgumentNullException(nameof(model));
if (!model.Id.HasValue)
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure));
var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration);
var passwordEditOnly = !callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers);
var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers);
var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword);
var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnOAuthConnections);
var originalUser = passwordEditOnly
var originalUser = !canEditAllUsers
? AuthenticationContext.User
: await DatabaseContext
.Users
.AsQueryable()
.Where(x => x.Id == model.Id)
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
@@ -214,13 +214,15 @@ namespace Tgstation.Server.Host.Controllers
if (originalUser.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
return Forbid();
// Ensure they are only trying to edit password (system identity change will trigger a bad request)
if (passwordEditOnly
// Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request)
if ((!canEditAllUsers
&& (model.Id != originalUser.Id
|| model.InstanceManagerRights.HasValue
|| model.AdministrationRights.HasValue
|| model.Enabled.HasValue
|| model.Name != null))
|| (!passwordEdit && model.Password != null)
|| (!oAuthEdit && model.OAuthConnections != null))
return Forbid();
if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier)
@@ -246,6 +248,22 @@ namespace Tgstation.Server.Host.Controllers
originalUser.Enabled = model.Enabled.Value;
}
if (model.OAuthConnections != null
&& (model.OAuthConnections.Count != originalUser.OAuthConnections.Count
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
{
if (originalUser.CanonicalName == Models.User.CanonicalizeName(Api.Models.User.AdminName))
return BadRequest(new ErrorMessage(ErrorCode.AdminUserCannotOAuth));
originalUser.OAuthConnections.Clear();
foreach (var updatedConnection in model.OAuthConnections)
originalUser.OAuthConnections.Add(new Models.OAuthConnection
{
Provider = updatedConnection.Provider,
ExternalUserId = updatedConnection.ExternalUserId
});
}
var fail = CheckValidName(model, false);
if (fail != null)
return fail;
@@ -254,6 +272,8 @@ namespace Tgstation.Server.Host.Controllers
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
Logger.LogInformation("Updated user {0} ({1})", originalUser.Name, originalUser.Id);
// return id only if not a self update and cannot read users
return Json(
model.Id == originalUser.Id
@@ -293,6 +313,7 @@ namespace Tgstation.Server.Host.Controllers
.AsQueryable()
.Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.ToListAsync(cancellationToken).ConfigureAwait(false);
return Json(users.Select(x => x.ToApi(true)));
}
@@ -321,6 +342,7 @@ namespace Tgstation.Server.Host.Controllers
.AsQueryable()
.Where(x => x.Id == id)
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (user == default)
return NotFound();
@@ -330,5 +352,30 @@ namespace Tgstation.Server.Host.Controllers
return Json(user.ToApi(true));
}
/// <summary>
/// Creates a new <see cref="User"/> from a given <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.User"/> to use as a template.</param>
/// <returns>A new <see cref="User"/>.</returns>
Models.User CreateNewUserFromModel(Api.Models.User model) => new Models.User
{
AdministrationRights = RightsHelper.Clamp(model.AdministrationRights ?? AdministrationRights.None),
CreatedAt = DateTimeOffset.Now,
CreatedBy = AuthenticationContext.User,
Enabled = model.Enabled ?? false,
InstanceManagerRights = RightsHelper.Clamp(model.InstanceManagerRights ?? InstanceManagerRights.None),
Name = model.Name,
SystemIdentifier = model.SystemIdentifier,
InstanceUsers = new List<Models.InstanceUser>(),
OAuthConnections = model
.OAuthConnections
?.Select(x => new Models.OAuthConnection
{
Provider = x.Provider,
ExternalUserId = x.ExternalUserId
})
.ToList()
};
}
}
+27 -18
View File
@@ -37,6 +37,7 @@ using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Properties;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
using Tgstation.Server.Host.Setup;
using Tgstation.Server.Host.System;
@@ -100,7 +101,6 @@ namespace Tgstation.Server.Host.Core
// configure configuration
services.UseStandardConfig<UpdatesConfiguration>(Configuration);
services.UseStandardConfig<ControlPanelConfiguration>(Configuration);
services.UseStandardConfig<SecurityConfiguration>(Configuration);
// enable options which give us config reloading
services.AddOptions();
@@ -127,7 +127,10 @@ namespace Tgstation.Server.Host.Core
config =>
{
if (microsoftEventLevel.HasValue)
{
config.MinimumLevel.Override("Microsoft", microsoftEventLevel.Value);
config.MinimumLevel.Override("System.Net.Http.HttpClient", microsoftEventLevel.Value);
}
},
sinkConfig =>
{
@@ -159,24 +162,26 @@ namespace Tgstation.Server.Host.Core
});
// configure bearer token validation
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(jwtBearerOptions =>
{
// this line isn't actually run until the first request is made
// at that point tokenFactory will be populated
jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters;
jwtBearerOptions.Events = new JwtBearerEvents
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwtBearerOptions =>
{
// Application is our composition root so this monstrosity of a line is okay
// At least, that's what I tell myself to sleep at night
OnTokenValidated = ctx => ctx
.HttpContext
.RequestServices
.GetRequiredService<IClaimsInjector>()
.InjectClaimsIntoContext(
ctx,
ctx.HttpContext.RequestAborted)
};
});
// this line isn't actually run until the first request is made
// at that point tokenFactory will be populated
jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters;
jwtBearerOptions.Events = new JwtBearerEvents
{
// Application is our composition root so this monstrosity of a line is okay
// At least, that's what I tell myself to sleep at night
OnTokenValidated = ctx => ctx
.HttpContext
.RequestServices
.GetRequiredService<IClaimsInjector>()
.InjectClaimsIntoContext(
ctx,
ctx.HttpContext.RequestAborted)
};
});
// WARNING: STATIC CODE
// fucking prevents converting 'sub' to M$ bs
@@ -216,6 +221,9 @@ namespace Tgstation.Server.Host.Core
// CORS conditionally enabled later
services.AddCors();
// Enable managed HTTP clients
services.AddHttpClient();
void AddTypedContext<TContext>() where TContext : DatabaseContext
{
var configureAction = DatabaseContext.GetConfigureAction<TContext>();
@@ -260,6 +268,7 @@ namespace Tgstation.Server.Host.Core
// configure security services
services.AddScoped<IAuthenticationContextFactory, AuthenticationContextFactory>();
services.AddScoped<IClaimsInjector, ClaimsInjector>();
services.AddSingleton<IOAuthProviders, OAuthProviders>();
services.AddSingleton<IIdentityCache, IdentityCache>();
services.AddSingleton<ICryptographySuite, CryptographySuite>();
services.AddSingleton<ITokenFactory, TokenFactory>();
@@ -1,4 +1,4 @@
using System;
using System;
using Octokit;
using Tgstation.Server.Host.System;
@@ -25,7 +25,10 @@ namespace Tgstation.Server.Host.Core
/// Create a <see cref="GitHubClient"/>
/// </summary>
/// <returns>A new <see cref="GitHubClient"/></returns>
GitHubClient CreateBaseClient() => new GitHubClient(new ProductHeaderValue(assemblyInformationProvider.VersionPrefix, assemblyInformationProvider.Version.ToString()));
GitHubClient CreateBaseClient() => new GitHubClient(
new ProductHeaderValue(
assemblyInformationProvider.ProductInfoHeaderValue.Product.Name,
assemblyInformationProvider.ProductInfoHeaderValue.Product.Version));
/// <inheritdoc />
public IGitHubClient CreateClient() => CreateBaseClient();
@@ -176,7 +176,7 @@ namespace Tgstation.Server.Host.Core
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Name = HeaderNames.Authorization,
Scheme = ApiHeaders.JwtAuthenticationScheme
Scheme = ApiHeaders.BearerAuthenticationScheme
});
}
@@ -87,10 +87,15 @@ namespace Tgstation.Server.Host.Database
public DbSet<TestMerge> TestMerges { get; set; }
/// <summary>
/// The <see cref="RevInfoTestMerge"/>s om the <see cref="DatabaseContext"/>
/// The <see cref="RevInfoTestMerge"/>s in the <see cref="DatabaseContext"/>
/// </summary>
public DbSet<RevInfoTestMerge> RevInfoTestMerges { get; set; }
/// <summary>
/// The <see cref="OAuthConnection"/>s in the <see cref="DatabaseContext"/>
/// </summary>
public DbSet<OAuthConnection> OAuthConnections { get; set; }
/// <summary>
/// The <see cref="DeleteBehavior"/> for the <see cref="CompileJob"/>/<see cref="RevisionInformation"/> foreign key.
/// </summary>
@@ -132,6 +137,9 @@ namespace Tgstation.Server.Host.Database
/// <inheritdoc />
IDatabaseCollection<ReattachInformation> IDatabaseContext.ReattachInformations => reattachInformationsCollection;
/// <inheritdoc />
IDatabaseCollection<OAuthConnection> IDatabaseContext.OAuthConnections => oAuthConnections;
/// <summary>
/// Backing field for <see cref="IDatabaseContext.Users"/>.
/// </summary>
@@ -192,6 +200,11 @@ namespace Tgstation.Server.Host.Database
/// </summary>
readonly IDatabaseCollection<ReattachInformation> reattachInformationsCollection;
/// <summary>
/// Backing field for <see cref="IDatabaseContext.OAuthConnections"/>.
/// </summary>
readonly IDatabaseCollection<OAuthConnection> oAuthConnections;
/// <summary>
/// Gets the configure action for a given <typeparamref name="TDatabaseContext"/>.
/// </summary>
@@ -216,7 +229,7 @@ namespace Tgstation.Server.Host.Database
/// Construct a <see cref="DatabaseContext"/>
/// </summary>
/// <param name="dbContextOptions">The <see cref="DbContextOptions"/> for the <see cref="DatabaseContext"/>.</param>
public DatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions)
protected DatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions)
{
usersCollection = new DatabaseCollection<User>(Users);
instancesCollection = new DatabaseCollection<Instance>(Instances);
@@ -230,6 +243,7 @@ namespace Tgstation.Server.Host.Database
revisionInformationsCollection = new DatabaseCollection<RevisionInformation>(RevisionInformations);
jobsCollection = new DatabaseCollection<Job>(Jobs);
reattachInformationsCollection = new DatabaseCollection<ReattachInformation>(ReattachInformations);
oAuthConnections = new DatabaseCollection<OAuthConnection>(OAuthConnections);
}
/// <inheritdoc />
@@ -244,6 +258,9 @@ namespace Tgstation.Server.Host.Database
userModel.HasIndex(x => x.CanonicalName).IsUnique();
userModel.HasIndex(x => x.SystemIdentifier).IsUnique();
userModel.HasMany(x => x.TestMerges).WithOne(x => x.MergedBy).OnDelete(DeleteBehavior.Restrict);
userModel.HasMany(x => x.OAuthConnections).WithOne(x => x.User).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<OAuthConnection>().HasIndex(x => new { x.Provider, x.ExternalUserId }).IsUnique();
modelBuilder.Entity<InstanceUser>().HasIndex(x => new { x.UserId, x.InstanceId }).IsUnique();
@@ -316,6 +333,7 @@ namespace Tgstation.Server.Host.Database
}
/// <inheritdoc />
#pragma warning disable CA1502 // Cyclomatic complexity
public async Task SchemaDowngradeForServerVersion(
ILogger<DatabaseContext> logger,
Version version,
@@ -338,7 +356,43 @@ namespace Tgstation.Server.Host.Database
if (version < new Version(4, 1, 0))
throw new NotSupportedException("Cannot migrate below version 4.1.0!");
if(version < new Version(4, 4, 0))
if (version < new Version(4, 6, 0))
switch (currentDatabaseType)
{
case DatabaseType.MariaDB:
case DatabaseType.MySql:
targetMigration = nameof(MYAddAdditionalDDParameters);
break;
case DatabaseType.PostgresSql:
targetMigration = nameof(PGAddAdditionalDDParameters);
break;
case DatabaseType.SqlServer:
case DatabaseType.Sqlite:
targetMigration = nameof(MSAddAdditionalDDParameters);
break;
default:
throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType));
}
if (version < new Version(4, 5, 0))
switch (currentDatabaseType)
{
case DatabaseType.MariaDB:
case DatabaseType.MySql:
targetMigration = nameof(MYAddDeploymentColumns);
break;
case DatabaseType.PostgresSql:
targetMigration = nameof(PGAddDeploymentColumns);
break;
case DatabaseType.SqlServer:
case DatabaseType.Sqlite:
targetMigration = nameof(MSAddDeploymentColumns);
break;
default:
throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType));
}
if (version < new Version(4, 4, 0))
switch (currentDatabaseType)
{
case DatabaseType.MariaDB:
@@ -403,5 +457,6 @@ namespace Tgstation.Server.Host.Database
logger.LogCritical(e, "Failed to migrate!");
}
}
#pragma warning restore CA1502 // Cyclomatic complexity
}
}
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
@@ -14,65 +14,70 @@ namespace Tgstation.Server.Host.Database
public interface IDatabaseContext
{
/// <summary>
/// The <see cref="User"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="User"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<User> Users { get; }
/// <summary>
/// The <see cref="Instance"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="Instance"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<Instance> Instances { get; }
/// <summary>
/// The <see cref="InstanceUser"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="InstanceUser"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<InstanceUser> InstanceUsers { get; }
/// <summary>
/// The <see cref="Job"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="Job"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<Job> Jobs { get; }
/// <summary>
/// The <see cref="CompileJob"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="CompileJob"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<CompileJob> CompileJobs { get; }
/// <summary>
/// The <see cref="RevisionInformation"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="RevisionInformation"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<RevisionInformation> RevisionInformations { get; }
/// <summary>
/// The <see cref="Models.DreamMakerSettings"/> in the <see cref="IDatabaseContext"/>
/// The <see cref="Models.DreamMakerSettings"/> in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<DreamMakerSettings> DreamMakerSettings { get; }
/// <summary>
/// The <see cref="Models.DreamDaemonSettings"/> in the <see cref="IDatabaseContext"/>
/// The <see cref="Models.DreamDaemonSettings"/> in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<DreamDaemonSettings> DreamDaemonSettings { get; }
/// <summary>
/// The <see cref="ChatBot"/>s in the <see cref="IDatabaseContext"/>
/// The <see cref="ChatBot"/>s in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<ChatBot> ChatBots { get; }
/// <summary>
/// The <see cref="ChatChannel"/> in the <see cref="IDatabaseContext"/>
/// The <see cref="ChatChannel"/> in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<ChatChannel> ChatChannels { get; }
/// <summary>
/// The <see cref="Models.RepositorySettings"/> in the <see cref="IDatabaseContext"/>
/// The <see cref="Models.RepositorySettings"/> in the <see cref="IDatabaseContext"/>.
/// </summary>
IDatabaseCollection<RepositorySettings> RepositorySettings { get; }
/// <summary>
/// The <see cref="DbSet{TEntity}"/> for <see cref="ReattachInformation"/>s
/// The <see cref="DbSet{TEntity}"/> for <see cref="ReattachInformation"/>s.
/// </summary>
IDatabaseCollection<ReattachInformation> ReattachInformations { get; }
/// <summary>
/// The <see cref="DbSet{TEntity}"/> for <see cref="OAuthConnection"/>s.
/// </summary>
IDatabaseCollection<OAuthConnection> OAuthConnections { get; }
/// <summary>
/// Saves changes made to the <see cref="IDatabaseContext"/>
/// </summary>
@@ -0,0 +1,822 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Tgstation.Server.Host.Database.Migrations
{
[DbContext(typeof(SqlServerDatabaseContext))]
[Migration("20201122231219_MSAddOAuthConnections")]
partial class MSAddOAuthConnections
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<int>("ChannelLimit")
.HasColumnType("int");
b.Property<string>("ConnectionString")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<bool?>("Enabled")
.HasColumnType("bit");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(100)")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<long>("ReconnectionInterval")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId", "Name")
.IsUnique();
b.ToTable("ChatBots");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<long>("ChatSettingsId")
.HasColumnType("bigint");
b.Property<decimal?>("DiscordChannelId")
.HasColumnType("decimal(20,0)");
b.Property<string>("IrcChannel")
.HasColumnType("nvarchar(100)")
.HasMaxLength(100);
b.Property<bool?>("IsAdminChannel")
.IsRequired()
.HasColumnType("bit");
b.Property<bool?>("IsUpdatesChannel")
.IsRequired()
.HasColumnType("bit");
b.Property<bool?>("IsWatchdogChannel")
.IsRequired()
.HasColumnType("bit");
b.Property<string>("Tag")
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.HasKey("Id");
b.HasIndex("ChatSettingsId", "DiscordChannelId")
.IsUnique()
.HasFilter("[DiscordChannelId] IS NOT NULL");
b.HasIndex("ChatSettingsId", "IrcChannel")
.IsUnique()
.HasFilter("[IrcChannel] IS NOT NULL");
b.ToTable("ChatChannels");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("ByondVersion")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("DMApiMajorVersion")
.HasColumnType("int");
b.Property<int?>("DMApiMinorVersion")
.HasColumnType("int");
b.Property<int?>("DMApiPatchVersion")
.HasColumnType("int");
b.Property<Guid?>("DirectoryName")
.IsRequired()
.HasColumnType("uniqueidentifier");
b.Property<string>("DmeName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("GitHubDeploymentId")
.HasColumnType("int");
b.Property<long?>("GitHubRepoId")
.HasColumnType("bigint");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<int?>("MinimumSecurityLevel")
.HasColumnType("int");
b.Property<string>("Output")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("RevisionInformationId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("DirectoryName");
b.HasIndex("JobId")
.IsUnique();
b.HasIndex("RevisionInformationId");
b.ToTable("CompileJobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("AdditionalParameters")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<bool?>("AllowWebClient")
.IsRequired()
.HasColumnType("bit");
b.Property<bool?>("AutoStart")
.IsRequired()
.HasColumnType("bit");
b.Property<long>("HeartbeatSeconds")
.HasColumnType("bigint");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<int>("Port")
.HasColumnType("int");
b.Property<int>("SecurityLevel")
.HasColumnType("int");
b.Property<long>("StartupTimeout")
.HasColumnType("bigint");
b.Property<long>("TopicRequestTimeout")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamDaemonSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<int>("ApiValidationPort")
.HasColumnType("int");
b.Property<int>("ApiValidationSecurityLevel")
.HasColumnType("int");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<bool?>("RequireDMApiValidation")
.IsRequired()
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamMakerSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<long>("AutoUpdateInterval")
.HasColumnType("bigint");
b.Property<int>("ChatBotLimit")
.HasColumnType("int");
b.Property<int>("ConfigurationType")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<bool?>("Online")
.IsRequired()
.HasColumnType("bit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("Path")
.IsUnique();
b.ToTable("Instances");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<decimal>("ByondRights")
.HasColumnType("decimal(20,0)");
b.Property<decimal>("ChatBotRights")
.HasColumnType("decimal(20,0)");
b.Property<decimal>("ConfigurationRights")
.HasColumnType("decimal(20,0)");
b.Property<decimal>("DreamDaemonRights")
.HasColumnType("decimal(20,0)");
b.Property<decimal>("DreamMakerRights")
.HasColumnType("decimal(20,0)");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<decimal>("InstanceUserRights")
.HasColumnType("decimal(20,0)");
b.Property<decimal>("RepositoryRights")
.HasColumnType("decimal(20,0)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("UserId", "InstanceId")
.IsUnique();
b.ToTable("InstanceUsers");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<decimal?>("CancelRight")
.HasColumnType("decimal(20,0)");
b.Property<decimal?>("CancelRightsType")
.HasColumnType("decimal(20,0)");
b.Property<bool?>("Cancelled")
.IsRequired()
.HasColumnType("bit");
b.Property<long?>("CancelledById")
.HasColumnType("bigint");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long?>("ErrorCode")
.HasColumnType("bigint");
b.Property<string>("ExceptionDetails")
.HasColumnType("nvarchar(max)");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("datetimeoffset");
b.Property<long>("StartedById")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StoppedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("CancelledById");
b.HasIndex("InstanceId");
b.HasIndex("StartedById");
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("nvarchar(100)")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("AccessIdentifier")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("CompileJobId")
.HasColumnType("bigint");
b.Property<int>("LaunchSecurityLevel")
.HasColumnType("int");
b.Property<int>("Port")
.HasColumnType("int");
b.Property<int>("ProcessId")
.HasColumnType("int");
b.Property<int>("RebootState")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CompileJobId");
b.ToTable("ReattachInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("AccessToken")
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<string>("AccessUser")
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<bool?>("AutoUpdatesKeepTestMerges")
.IsRequired()
.HasColumnType("bit");
b.Property<bool?>("AutoUpdatesSynchronize")
.IsRequired()
.HasColumnType("bit");
b.Property<string>("CommitterEmail")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<string>("CommitterName")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<bool?>("CreateGitHubDeployments")
.IsRequired()
.HasColumnType("bit");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<bool?>("PostTestMergeComment")
.IsRequired()
.HasColumnType("bit");
b.Property<bool?>("PushTestMergeCommits")
.IsRequired()
.HasColumnType("bit");
b.Property<bool?>("ShowTestMergeCommitters")
.IsRequired()
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("RepositorySettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<long>("RevisionInformationId")
.HasColumnType("bigint");
b.Property<long>("TestMergeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RevisionInformationId");
b.HasIndex("TestMergeId");
b.ToTable("RevInfoTestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("CommitSha")
.IsRequired()
.HasColumnType("nvarchar(40)")
.HasMaxLength(40);
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("OriginCommitSha")
.IsRequired()
.HasColumnType("nvarchar(40)")
.HasMaxLength(40);
b.HasKey("Id");
b.HasIndex("InstanceId", "CommitSha")
.IsUnique();
b.ToTable("RevisionInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("Author")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("BodyAtMerge")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Comment")
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<DateTimeOffset>("MergedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("MergedById")
.HasColumnType("bigint");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<long?>("PrimaryRevisionInformationId")
.IsRequired()
.HasColumnType("bigint");
b.Property<string>("PullRequestRevision")
.IsRequired()
.HasColumnType("nvarchar(40)")
.HasMaxLength(40);
b.Property<string>("TitleAtMerge")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("MergedById");
b.HasIndex("PrimaryRevisionInformationId")
.IsUnique();
b.ToTable("TestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.Property<long?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<decimal>("AdministrationRights")
.HasColumnType("decimal(20,0)");
b.Property<string>("CanonicalName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTimeOffset?>("CreatedAt")
.IsRequired()
.HasColumnType("datetimeoffset");
b.Property<long?>("CreatedById")
.HasColumnType("bigint");
b.Property<bool?>("Enabled")
.IsRequired()
.HasColumnType("bit");
b.Property<decimal>("InstanceManagerRights")
.HasColumnType("decimal(20,0)");
b.Property<DateTimeOffset?>("LastPasswordUpdate")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasMaxLength(10000);
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("SystemIdentifier")
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("CanonicalName")
.IsUnique();
b.HasIndex("CreatedById");
b.HasIndex("SystemIdentifier")
.IsUnique()
.HasFilter("[SystemIdentifier] IS NOT NULL");
b.ToTable("Users");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("ChatSettings")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings")
.WithMany("Channels")
.HasForeignKey("ChatSettingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Job", "Job")
.WithOne()
.HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("CompileJobs")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.ClientNoAction)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamDaemonSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamMakerSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("InstanceUsers")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", null)
.WithMany("InstanceUsers")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy")
.WithMany()
.HasForeignKey("CancelledById");
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("Jobs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy")
.WithMany()
.HasForeignKey("StartedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
.WithMany()
.HasForeignKey("CompileJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("RepositorySettings")
.HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("ActiveTestMerges")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge")
.WithMany("RevisonInformations")
.HasForeignKey("TestMergeId")
.OnDelete(DeleteBehavior.ClientNoAction)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("RevisionInformations")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy")
.WithMany("TestMerges")
.HasForeignKey("MergedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation")
.WithOne("PrimaryTestMerge")
.HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy")
.WithMany("CreatedUsers")
.HasForeignKey("CreatedById");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <summary>
/// Adds the OAuthConnections table for MSSQL.
/// </summary>
public partial class MSAddOAuthConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.CreateTable(
name: "OAuthConnections",
columns: table => new
{
Id = table.Column<long>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Provider = table.Column<int>(nullable: false),
ExternalUserId = table.Column<string>(nullable: false, maxLength: 100),
UserId = table.Column<long>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OAuthConnections", x => x.Id);
table.ForeignKey(
name: "FK_OAuthConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_UserId",
table: "OAuthConnections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_Provider_ExternalUserId",
table: "OAuthConnections",
columns: new[] { "Provider", "ExternalUserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.DropTable(
name: "OAuthConnections");
}
}
}
@@ -0,0 +1,811 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Tgstation.Server.Host.Database.Migrations
{
[DbContext(typeof(MySqlDatabaseContext))]
[Migration("20201122231327_MYAddOAuthConnections")]
partial class MYAddOAuthConnections
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.10")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<ushort?>("ChannelLimit")
.IsRequired()
.HasColumnType("smallint unsigned");
b.Property<string>("ConnectionString")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<bool?>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("varchar(100) CHARACTER SET utf8mb4")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<uint?>("ReconnectionInterval")
.IsRequired()
.HasColumnType("int unsigned");
b.HasKey("Id");
b.HasIndex("InstanceId", "Name")
.IsUnique();
b.ToTable("ChatBots");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<long>("ChatSettingsId")
.HasColumnType("bigint");
b.Property<ulong?>("DiscordChannelId")
.HasColumnType("bigint unsigned");
b.Property<string>("IrcChannel")
.HasColumnType("varchar(100) CHARACTER SET utf8mb4")
.HasMaxLength(100);
b.Property<bool?>("IsAdminChannel")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<bool?>("IsUpdatesChannel")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<bool?>("IsWatchdogChannel")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<string>("Tag")
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.HasKey("Id");
b.HasIndex("ChatSettingsId", "DiscordChannelId")
.IsUnique();
b.HasIndex("ChatSettingsId", "IrcChannel")
.IsUnique();
b.ToTable("ChatChannels");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("ByondVersion")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<int?>("DMApiMajorVersion")
.HasColumnType("int");
b.Property<int?>("DMApiMinorVersion")
.HasColumnType("int");
b.Property<int?>("DMApiPatchVersion")
.HasColumnType("int");
b.Property<Guid?>("DirectoryName")
.IsRequired()
.HasColumnType("char(36)");
b.Property<string>("DmeName")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<int?>("GitHubDeploymentId")
.HasColumnType("int");
b.Property<long?>("GitHubRepoId")
.HasColumnType("bigint");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<int?>("MinimumSecurityLevel")
.HasColumnType("int");
b.Property<string>("Output")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<long>("RevisionInformationId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("DirectoryName");
b.HasIndex("JobId")
.IsUnique();
b.HasIndex("RevisionInformationId");
b.ToTable("CompileJobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("AdditionalParameters")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<bool?>("AllowWebClient")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<bool?>("AutoStart")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<uint?>("HeartbeatSeconds")
.IsRequired()
.HasColumnType("int unsigned");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<ushort?>("Port")
.IsRequired()
.HasColumnType("smallint unsigned");
b.Property<int>("SecurityLevel")
.HasColumnType("int");
b.Property<uint?>("StartupTimeout")
.IsRequired()
.HasColumnType("int unsigned");
b.Property<uint?>("TopicRequestTimeout")
.IsRequired()
.HasColumnType("int unsigned");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamDaemonSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<ushort?>("ApiValidationPort")
.IsRequired()
.HasColumnType("smallint unsigned");
b.Property<int>("ApiValidationSecurityLevel")
.HasColumnType("int");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("ProjectName")
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<bool?>("RequireDMApiValidation")
.IsRequired()
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamMakerSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<uint?>("AutoUpdateInterval")
.IsRequired()
.HasColumnType("int unsigned");
b.Property<ushort?>("ChatBotLimit")
.IsRequired()
.HasColumnType("smallint unsigned");
b.Property<int>("ConfigurationType")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<bool?>("Online")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("varchar(255) CHARACTER SET utf8mb4");
b.HasKey("Id");
b.HasIndex("Path")
.IsUnique();
b.ToTable("Instances");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<ulong>("ByondRights")
.HasColumnType("bigint unsigned");
b.Property<ulong>("ChatBotRights")
.HasColumnType("bigint unsigned");
b.Property<ulong>("ConfigurationRights")
.HasColumnType("bigint unsigned");
b.Property<ulong>("DreamDaemonRights")
.HasColumnType("bigint unsigned");
b.Property<ulong>("DreamMakerRights")
.HasColumnType("bigint unsigned");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<ulong>("InstanceUserRights")
.HasColumnType("bigint unsigned");
b.Property<ulong>("RepositoryRights")
.HasColumnType("bigint unsigned");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("UserId", "InstanceId")
.IsUnique();
b.ToTable("InstanceUsers");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<ulong?>("CancelRight")
.HasColumnType("bigint unsigned");
b.Property<ulong?>("CancelRightsType")
.HasColumnType("bigint unsigned");
b.Property<bool?>("Cancelled")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<long?>("CancelledById")
.HasColumnType("bigint");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<uint?>("ErrorCode")
.HasColumnType("int unsigned");
b.Property<string>("ExceptionDetails")
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("datetime(6)");
b.Property<long>("StartedById")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StoppedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CancelledById");
b.HasIndex("InstanceId");
b.HasIndex("StartedById");
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("varchar(100) CHARACTER SET utf8mb4")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("AccessIdentifier")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<long>("CompileJobId")
.HasColumnType("bigint");
b.Property<int>("LaunchSecurityLevel")
.HasColumnType("int");
b.Property<ushort>("Port")
.HasColumnType("smallint unsigned");
b.Property<int>("ProcessId")
.HasColumnType("int");
b.Property<int>("RebootState")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CompileJobId");
b.ToTable("ReattachInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("AccessToken")
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<string>("AccessUser")
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<bool?>("AutoUpdatesKeepTestMerges")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<bool?>("AutoUpdatesSynchronize")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<string>("CommitterEmail")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<string>("CommitterName")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<bool?>("CreateGitHubDeployments")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<bool?>("PostTestMergeComment")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<bool?>("PushTestMergeCommits")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<bool?>("ShowTestMergeCommitters")
.IsRequired()
.HasColumnType("tinyint(1)");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("RepositorySettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<long>("RevisionInformationId")
.HasColumnType("bigint");
b.Property<long>("TestMergeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RevisionInformationId");
b.HasIndex("TestMergeId");
b.ToTable("RevInfoTestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("CommitSha")
.IsRequired()
.HasColumnType("varchar(40) CHARACTER SET utf8mb4")
.HasMaxLength(40);
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("OriginCommitSha")
.IsRequired()
.HasColumnType("varchar(40) CHARACTER SET utf8mb4")
.HasMaxLength(40);
b.HasKey("Id");
b.HasIndex("InstanceId", "CommitSha")
.IsUnique();
b.ToTable("RevisionInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("Author")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<string>("BodyAtMerge")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<string>("Comment")
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<DateTimeOffset>("MergedAt")
.HasColumnType("datetime(6)");
b.Property<long>("MergedById")
.HasColumnType("bigint");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<long?>("PrimaryRevisionInformationId")
.IsRequired()
.HasColumnType("bigint");
b.Property<string>("PullRequestRevision")
.IsRequired()
.HasColumnType("varchar(40) CHARACTER SET utf8mb4")
.HasMaxLength(40);
b.Property<string>("TitleAtMerge")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.HasKey("Id");
b.HasIndex("MergedById");
b.HasIndex("PrimaryRevisionInformationId")
.IsUnique();
b.ToTable("TestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.Property<long?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<ulong>("AdministrationRights")
.HasColumnType("bigint unsigned");
b.Property<string>("CanonicalName")
.IsRequired()
.HasColumnType("varchar(255) CHARACTER SET utf8mb4");
b.Property<DateTimeOffset?>("CreatedAt")
.IsRequired()
.HasColumnType("datetime(6)");
b.Property<long?>("CreatedById")
.HasColumnType("bigint");
b.Property<bool?>("Enabled")
.IsRequired()
.HasColumnType("tinyint(1)");
b.Property<ulong>("InstanceManagerRights")
.HasColumnType("bigint unsigned");
b.Property<DateTimeOffset?>("LastPasswordUpdate")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext CHARACTER SET utf8mb4")
.HasMaxLength(10000);
b.Property<string>("PasswordHash")
.HasColumnType("longtext CHARACTER SET utf8mb4");
b.Property<string>("SystemIdentifier")
.HasColumnType("varchar(255) CHARACTER SET utf8mb4");
b.HasKey("Id");
b.HasIndex("CanonicalName")
.IsUnique();
b.HasIndex("CreatedById");
b.HasIndex("SystemIdentifier")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("ChatSettings")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings")
.WithMany("Channels")
.HasForeignKey("ChatSettingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Job", "Job")
.WithOne()
.HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("CompileJobs")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamDaemonSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamMakerSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("InstanceUsers")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", null)
.WithMany("InstanceUsers")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy")
.WithMany()
.HasForeignKey("CancelledById");
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("Jobs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy")
.WithMany()
.HasForeignKey("StartedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
.WithMany()
.HasForeignKey("CompileJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("RepositorySettings")
.HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("ActiveTestMerges")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge")
.WithMany("RevisonInformations")
.HasForeignKey("TestMergeId")
.OnDelete(DeleteBehavior.ClientNoAction)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("RevisionInformations")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy")
.WithMany("TestMerges")
.HasForeignKey("MergedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation")
.WithOne("PrimaryTestMerge")
.HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy")
.WithMany("CreatedUsers")
.HasForeignKey("CreatedById");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using System;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <summary>
/// Adds the OAuthConnections table for MYSQL.
/// </summary>
public partial class MYAddOAuthConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.CreateTable(
name: "OAuthConnections",
columns: table => new
{
Id = table.Column<long>(nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Provider = table.Column<int>(nullable: false),
ExternalUserId = table.Column<string>(nullable: false, maxLength: 100),
UserId = table.Column<long>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OAuthConnections", x => x.Id);
table.ForeignKey(
name: "FK_OAuthConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_UserId",
table: "OAuthConnections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_Provider_ExternalUserId",
table: "OAuthConnections",
columns: new[] { "Provider", "ExternalUserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.DropTable(
name: "OAuthConnections");
}
}
}
@@ -0,0 +1,819 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Tgstation.Server.Host.Database.Migrations
{
[DbContext(typeof(PostgresSqlDatabaseContext))]
[Migration("20201122231443_PGAddOAuthConnections")]
partial class PGAddOAuthConnections
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn)
.HasAnnotation("ProductVersion", "3.1.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<int>("ChannelLimit")
.HasColumnType("integer");
b.Property<string>("ConnectionString")
.IsRequired()
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<bool?>("Enabled")
.HasColumnType("boolean");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("character varying(100)")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("integer");
b.Property<long>("ReconnectionInterval")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId", "Name")
.IsUnique();
b.ToTable("ChatBots");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<long>("ChatSettingsId")
.HasColumnType("bigint");
b.Property<decimal?>("DiscordChannelId")
.HasColumnType("numeric(20,0)");
b.Property<string>("IrcChannel")
.HasColumnType("character varying(100)")
.HasMaxLength(100);
b.Property<bool?>("IsAdminChannel")
.IsRequired()
.HasColumnType("boolean");
b.Property<bool?>("IsUpdatesChannel")
.IsRequired()
.HasColumnType("boolean");
b.Property<bool?>("IsWatchdogChannel")
.IsRequired()
.HasColumnType("boolean");
b.Property<string>("Tag")
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.HasKey("Id");
b.HasIndex("ChatSettingsId", "DiscordChannelId")
.IsUnique();
b.HasIndex("ChatSettingsId", "IrcChannel")
.IsUnique();
b.ToTable("ChatChannels");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("ByondVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("DMApiMajorVersion")
.HasColumnType("integer");
b.Property<int?>("DMApiMinorVersion")
.HasColumnType("integer");
b.Property<int?>("DMApiPatchVersion")
.HasColumnType("integer");
b.Property<Guid?>("DirectoryName")
.IsRequired()
.HasColumnType("uuid");
b.Property<string>("DmeName")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("GitHubDeploymentId")
.HasColumnType("integer");
b.Property<long?>("GitHubRepoId")
.HasColumnType("bigint");
b.Property<long>("JobId")
.HasColumnType("bigint");
b.Property<int?>("MinimumSecurityLevel")
.HasColumnType("integer");
b.Property<string>("Output")
.IsRequired()
.HasColumnType("text");
b.Property<long>("RevisionInformationId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("DirectoryName");
b.HasIndex("JobId")
.IsUnique();
b.HasIndex("RevisionInformationId");
b.ToTable("CompileJobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AdditionalParameters")
.IsRequired()
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<bool?>("AllowWebClient")
.IsRequired()
.HasColumnType("boolean");
b.Property<bool?>("AutoStart")
.IsRequired()
.HasColumnType("boolean");
b.Property<long>("HeartbeatSeconds")
.HasColumnType("bigint");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<int>("Port")
.HasColumnType("integer");
b.Property<int>("SecurityLevel")
.HasColumnType("integer");
b.Property<long>("StartupTimeout")
.HasColumnType("bigint");
b.Property<long>("TopicRequestTimeout")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamDaemonSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<int>("ApiValidationPort")
.HasColumnType("integer");
b.Property<int>("ApiValidationSecurityLevel")
.HasColumnType("integer");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("ProjectName")
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<bool?>("RequireDMApiValidation")
.IsRequired()
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamMakerSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<long>("AutoUpdateInterval")
.HasColumnType("bigint");
b.Property<int>("ChatBotLimit")
.HasColumnType("integer");
b.Property<int>("ConfigurationType")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<bool?>("Online")
.IsRequired()
.HasColumnType("boolean");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Path")
.IsUnique();
b.ToTable("Instances");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<decimal>("ByondRights")
.HasColumnType("numeric(20,0)");
b.Property<decimal>("ChatBotRights")
.HasColumnType("numeric(20,0)");
b.Property<decimal>("ConfigurationRights")
.HasColumnType("numeric(20,0)");
b.Property<decimal>("DreamDaemonRights")
.HasColumnType("numeric(20,0)");
b.Property<decimal>("DreamMakerRights")
.HasColumnType("numeric(20,0)");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<decimal>("InstanceUserRights")
.HasColumnType("numeric(20,0)");
b.Property<decimal>("RepositoryRights")
.HasColumnType("numeric(20,0)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("UserId", "InstanceId")
.IsUnique();
b.ToTable("InstanceUsers");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<decimal?>("CancelRight")
.HasColumnType("numeric(20,0)");
b.Property<decimal?>("CancelRightsType")
.HasColumnType("numeric(20,0)");
b.Property<bool?>("Cancelled")
.IsRequired()
.HasColumnType("boolean");
b.Property<long?>("CancelledById")
.HasColumnType("bigint");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("ErrorCode")
.HasColumnType("bigint");
b.Property<string>("ExceptionDetails")
.HasColumnType("text");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("timestamp with time zone");
b.Property<long>("StartedById")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("StoppedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CancelledById");
b.HasIndex("InstanceId");
b.HasIndex("StartedById");
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("character varying(100)")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("integer");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AccessIdentifier")
.IsRequired()
.HasColumnType("text");
b.Property<long>("CompileJobId")
.HasColumnType("bigint");
b.Property<int>("LaunchSecurityLevel")
.HasColumnType("integer");
b.Property<int>("Port")
.HasColumnType("integer");
b.Property<int>("ProcessId")
.HasColumnType("integer");
b.Property<int>("RebootState")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CompileJobId");
b.ToTable("ReattachInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AccessToken")
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<string>("AccessUser")
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<bool?>("AutoUpdatesKeepTestMerges")
.IsRequired()
.HasColumnType("boolean");
b.Property<bool?>("AutoUpdatesSynchronize")
.IsRequired()
.HasColumnType("boolean");
b.Property<string>("CommitterEmail")
.IsRequired()
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<string>("CommitterName")
.IsRequired()
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<bool?>("CreateGitHubDeployments")
.IsRequired()
.HasColumnType("boolean");
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<bool?>("PostTestMergeComment")
.IsRequired()
.HasColumnType("boolean");
b.Property<bool?>("PushTestMergeCommits")
.IsRequired()
.HasColumnType("boolean");
b.Property<bool?>("ShowTestMergeCommitters")
.IsRequired()
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("RepositorySettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<long>("RevisionInformationId")
.HasColumnType("bigint");
b.Property<long>("TestMergeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("RevisionInformationId");
b.HasIndex("TestMergeId");
b.ToTable("RevInfoTestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("CommitSha")
.IsRequired()
.HasColumnType("character varying(40)")
.HasMaxLength(40);
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<string>("OriginCommitSha")
.IsRequired()
.HasColumnType("character varying(40)")
.HasMaxLength(40);
b.HasKey("Id");
b.HasIndex("InstanceId", "CommitSha")
.IsUnique();
b.ToTable("RevisionInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Author")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BodyAtMerge")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Comment")
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<DateTimeOffset>("MergedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("MergedById")
.HasColumnType("bigint");
b.Property<int>("Number")
.HasColumnType("integer");
b.Property<long?>("PrimaryRevisionInformationId")
.IsRequired()
.HasColumnType("bigint");
b.Property<string>("PullRequestRevision")
.IsRequired()
.HasColumnType("character varying(40)")
.HasMaxLength(40);
b.Property<string>("TitleAtMerge")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("MergedById");
b.HasIndex("PrimaryRevisionInformationId")
.IsUnique();
b.ToTable("TestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.Property<long?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<decimal>("AdministrationRights")
.HasColumnType("numeric(20,0)");
b.Property<string>("CanonicalName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp with time zone");
b.Property<long?>("CreatedById")
.HasColumnType("bigint");
b.Property<bool?>("Enabled")
.IsRequired()
.HasColumnType("boolean");
b.Property<decimal>("InstanceManagerRights")
.HasColumnType("numeric(20,0)");
b.Property<DateTimeOffset?>("LastPasswordUpdate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("character varying(10000)")
.HasMaxLength(10000);
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("SystemIdentifier")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CanonicalName")
.IsUnique();
b.HasIndex("CreatedById");
b.HasIndex("SystemIdentifier")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("ChatSettings")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings")
.WithMany("Channels")
.HasForeignKey("ChatSettingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Job", "Job")
.WithOne()
.HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("CompileJobs")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamDaemonSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamMakerSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("InstanceUsers")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", null)
.WithMany("InstanceUsers")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy")
.WithMany()
.HasForeignKey("CancelledById");
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("Jobs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy")
.WithMany()
.HasForeignKey("StartedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
.WithMany()
.HasForeignKey("CompileJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("RepositorySettings")
.HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("ActiveTestMerges")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge")
.WithMany("RevisonInformations")
.HasForeignKey("TestMergeId")
.OnDelete(DeleteBehavior.ClientNoAction)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("RevisionInformations")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy")
.WithMany("TestMerges")
.HasForeignKey("MergedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation")
.WithOne("PrimaryTestMerge")
.HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy")
.WithMany("CreatedUsers")
.HasForeignKey("CreatedById");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using System;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <summary>
/// Adds the OAuthConnections table for PostgresSQL.
/// </summary>
public partial class PGAddOAuthConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.CreateTable(
name: "OAuthConnections",
columns: table => new
{
Id = table.Column<long>(nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Provider = table.Column<int>(nullable: false),
ExternalUserId = table.Column<string>(nullable: false, maxLength: 100),
UserId = table.Column<long>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OAuthConnections", x => x.Id);
table.ForeignKey(
name: "FK_OAuthConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_UserId",
table: "OAuthConnections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_Provider_ExternalUserId",
table: "OAuthConnections",
columns: new[] { "Provider", "ExternalUserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.DropTable(
name: "OAuthConnections");
}
}
}
@@ -0,0 +1,810 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Tgstation.Server.Host.Database.Migrations
{
[DbContext(typeof(SqliteDatabaseContext))]
[Migration("20201122231546_SLAddOAuthConnections")]
partial class SLAddOAuthConnections
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.10");
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ushort?>("ChannelLimit")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<string>("ConnectionString")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<bool?>("Enabled")
.HasColumnType("INTEGER");
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<uint?>("ReconnectionInterval")
.IsRequired()
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InstanceId", "Name")
.IsUnique();
b.ToTable("ChatBots");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("ChatSettingsId")
.HasColumnType("INTEGER");
b.Property<ulong?>("DiscordChannelId")
.HasColumnType("INTEGER");
b.Property<string>("IrcChannel")
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<bool?>("IsAdminChannel")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<bool?>("IsUpdatesChannel")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<bool?>("IsWatchdogChannel")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<string>("Tag")
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.HasKey("Id");
b.HasIndex("ChatSettingsId", "DiscordChannelId")
.IsUnique();
b.HasIndex("ChatSettingsId", "IrcChannel")
.IsUnique();
b.ToTable("ChatChannels");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ByondVersion")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("DMApiMajorVersion")
.HasColumnType("INTEGER");
b.Property<int?>("DMApiMinorVersion")
.HasColumnType("INTEGER");
b.Property<int?>("DMApiPatchVersion")
.HasColumnType("INTEGER");
b.Property<Guid?>("DirectoryName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DmeName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("GitHubDeploymentId")
.HasColumnType("INTEGER");
b.Property<long?>("GitHubRepoId")
.HasColumnType("INTEGER");
b.Property<long>("JobId")
.HasColumnType("INTEGER");
b.Property<int?>("MinimumSecurityLevel")
.HasColumnType("INTEGER");
b.Property<string>("Output")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("RevisionInformationId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("DirectoryName");
b.HasIndex("JobId")
.IsUnique();
b.HasIndex("RevisionInformationId");
b.ToTable("CompileJobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AdditionalParameters")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<bool?>("AllowWebClient")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<bool?>("AutoStart")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<uint?>("HeartbeatSeconds")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<ushort?>("Port")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<int>("SecurityLevel")
.HasColumnType("INTEGER");
b.Property<uint?>("StartupTimeout")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<uint?>("TopicRequestTimeout")
.IsRequired()
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamDaemonSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ushort?>("ApiValidationPort")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<int>("ApiValidationSecurityLevel")
.HasColumnType("INTEGER");
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<string>("ProjectName")
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<bool?>("RequireDMApiValidation")
.IsRequired()
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DreamMakerSettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint?>("AutoUpdateInterval")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<ushort?>("ChatBotLimit")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<int>("ConfigurationType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<bool?>("Online")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Path")
.IsUnique();
b.ToTable("Instances");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ByondRights")
.HasColumnType("INTEGER");
b.Property<ulong>("ChatBotRights")
.HasColumnType("INTEGER");
b.Property<ulong>("ConfigurationRights")
.HasColumnType("INTEGER");
b.Property<ulong>("DreamDaemonRights")
.HasColumnType("INTEGER");
b.Property<ulong>("DreamMakerRights")
.HasColumnType("INTEGER");
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<ulong>("InstanceUserRights")
.HasColumnType("INTEGER");
b.Property<ulong>("RepositoryRights")
.HasColumnType("INTEGER");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("UserId", "InstanceId")
.IsUnique();
b.ToTable("InstanceUsers");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong?>("CancelRight")
.HasColumnType("INTEGER");
b.Property<ulong?>("CancelRightsType")
.HasColumnType("INTEGER");
b.Property<bool?>("Cancelled")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<long?>("CancelledById")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint?>("ErrorCode")
.HasColumnType("INTEGER");
b.Property<string>("ExceptionDetails")
.HasColumnType("TEXT");
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("StartedById")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("StoppedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CancelledById");
b.HasIndex("InstanceId");
b.HasIndex("StartedById");
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<long?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccessIdentifier")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("CompileJobId")
.HasColumnType("INTEGER");
b.Property<int>("LaunchSecurityLevel")
.HasColumnType("INTEGER");
b.Property<ushort>("Port")
.HasColumnType("INTEGER");
b.Property<int>("ProcessId")
.HasColumnType("INTEGER");
b.Property<int>("RebootState")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CompileJobId");
b.ToTable("ReattachInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccessToken")
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<string>("AccessUser")
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<bool?>("AutoUpdatesKeepTestMerges")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<bool?>("AutoUpdatesSynchronize")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<string>("CommitterEmail")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<string>("CommitterName")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<bool?>("CreateGitHubDeployments")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<bool?>("PostTestMergeComment")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<bool?>("PushTestMergeCommits")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<bool?>("ShowTestMergeCommitters")
.IsRequired()
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("RepositorySettings");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("RevisionInformationId")
.HasColumnType("INTEGER");
b.Property<long>("TestMergeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevisionInformationId");
b.HasIndex("TestMergeId");
b.ToTable("RevInfoTestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CommitSha")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(40);
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<string>("OriginCommitSha")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(40);
b.HasKey("Id");
b.HasIndex("InstanceId", "CommitSha")
.IsUnique();
b.ToTable("RevisionInformations");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Author")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("BodyAtMerge")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Comment")
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<DateTimeOffset>("MergedAt")
.HasColumnType("TEXT");
b.Property<long>("MergedById")
.HasColumnType("INTEGER");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<long?>("PrimaryRevisionInformationId")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<string>("PullRequestRevision")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(40);
b.Property<string>("TitleAtMerge")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MergedById");
b.HasIndex("PrimaryRevisionInformationId")
.IsUnique();
b.ToTable("TestMerges");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.Property<long?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("AdministrationRights")
.HasColumnType("INTEGER");
b.Property<string>("CanonicalName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long?>("CreatedById")
.HasColumnType("INTEGER");
b.Property<bool?>("Enabled")
.IsRequired()
.HasColumnType("INTEGER");
b.Property<ulong>("InstanceManagerRights")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LastPasswordUpdate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(10000);
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("SystemIdentifier")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CanonicalName")
.IsUnique();
b.HasIndex("CreatedById");
b.HasIndex("SystemIdentifier")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("ChatSettings")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
{
b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings")
.WithMany("Channels")
.HasForeignKey("ChatSettingsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Job", "Job")
.WithOne()
.HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("CompileJobs")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.ClientNoAction)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamDaemonSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("DreamMakerSettings")
.HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("InstanceUsers")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", null)
.WithMany("InstanceUsers")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy")
.WithMany()
.HasForeignKey("CancelledById");
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("Jobs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy")
.WithMany()
.HasForeignKey("StartedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
.WithMany()
.HasForeignKey("CompileJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithOne("RepositorySettings")
.HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
.WithMany("ActiveTestMerges")
.HasForeignKey("RevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge")
.WithMany("RevisonInformations")
.HasForeignKey("TestMergeId")
.OnDelete(DeleteBehavior.ClientNoAction)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
.WithMany("RevisionInformations")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy")
.WithMany("TestMerges")
.HasForeignKey("MergedById")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation")
.WithOne("PrimaryTestMerge")
.HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy")
.WithMany("CreatedUsers")
.HasForeignKey("CreatedById");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
namespace Tgstation.Server.Host.Database.Migrations
{
/// <summary>
/// Adds the OAuthConnections table for SQLite.
/// </summary>
public partial class SLAddOAuthConnections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.CreateTable(
name: "OAuthConnections",
columns: table => new
{
Id = table.Column<long>(nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Provider = table.Column<int>(nullable: false),
ExternalUserId = table.Column<string>(nullable: false, maxLength: 100),
UserId = table.Column<long>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OAuthConnections", x => x.Id);
table.ForeignKey(
name: "FK_OAuthConnections_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_UserId",
table: "OAuthConnections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_OAuthConnections_Provider_ExternalUserId",
table: "OAuthConnections",
columns: new[] { "Provider", "ExternalUserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
if (migrationBuilder == null)
throw new ArgumentNullException(nameof(migrationBuilder));
migrationBuilder.DropTable(
name: "OAuthConnections");
}
}
}
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.7")
.HasAnnotation("ProductVersion", "3.1.10")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
@@ -373,6 +373,33 @@ namespace Tgstation.Server.Host.Database.Migrations
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("varchar(100) CHARACTER SET utf8mb4")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
@@ -705,6 +732,14 @@ namespace Tgstation.Server.Host.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
@@ -15,7 +15,7 @@ namespace Tgstation.Server.Host.Database.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn)
.HasAnnotation("ProductVersion", "3.1.7")
.HasAnnotation("ProductVersion", "3.1.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
@@ -374,6 +374,34 @@ namespace Tgstation.Server.Host.Database.Migrations
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("character varying(100)")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("integer");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
@@ -712,6 +740,14 @@ namespace Tgstation.Server.Host.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
@@ -14,7 +14,7 @@ namespace Tgstation.Server.Host.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.7")
.HasAnnotation("ProductVersion", "3.1.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
@@ -376,6 +376,34 @@ namespace Tgstation.Server.Host.Database.Migrations
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("nvarchar(100)")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<long?>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
@@ -715,6 +743,14 @@ namespace Tgstation.Server.Host.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.7");
.HasAnnotation("ProductVersion", "3.1.10");
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
@@ -372,6 +372,33 @@ namespace Tgstation.Server.Host.Database.Migrations
b.ToTable("Jobs");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ExternalUserId")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<long?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Provider", "ExternalUserId")
.IsUnique();
b.ToTable("OAuthConnections");
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.Property<long>("Id")
@@ -704,6 +731,14 @@ namespace Tgstation.Server.Host.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
{
b.HasOne("Tgstation.Server.Host.Models.User", "User")
.WithMany("OAuthConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
{
b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
@@ -0,0 +1,26 @@
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc />
public sealed class OAuthConnection : Api.Models.OAuthConnection
{
/// <summary>
/// The row Id.
/// </summary>
public long Id { get; set; }
/// <summary>
/// The owning <see cref="Models.User"/>.
/// </summary>
public User User { get; set; }
/// <summary>
/// Convert the <see cref="OAuthConnection"/> to it's API form.
/// </summary>
/// <returns>A new <see cref="Api.Models.OAuthConnection"/>.</returns>
public Api.Models.OAuthConnection ToApi() => new Api.Models.OAuthConnection
{
Provider = Provider,
ExternalUserId = ExternalUserId
};
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
@@ -48,6 +48,11 @@ namespace Tgstation.Server.Host.Models
/// </summary>
public ICollection<TestMerge> TestMerges { get; set; }
/// <summary>
/// The <see cref="TestMerge"/>s made by the <see cref="User"/>
/// </summary>
public ICollection<OAuthConnection> OAuthConnections { get; set; }
/// <summary>
/// Change a <see cref="Api.Models.Internal.User.Name"/> into a <see cref="CanonicalName"/>.
/// </summary>
@@ -1,4 +1,4 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Tokens;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
@@ -19,8 +19,9 @@ namespace Tgstation.Server.Host.Security
/// Create a <see cref="Token"/> for a given <paramref name="user"/>
/// </summary>
/// <param name="user">The <see cref="Models.User"/> to create the token for. Must have the <see cref="Api.Models.Internal.User.Id"/> field available</param>
/// <param name="oAuth">Whether or not this is an OAuth login.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a new <see cref="Token"/></returns>
Task<Token> CreateToken(Models.User user, CancellationToken cancellationToken);
Task<Token> CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,101 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Base <see langword="class"/> for <see cref="IOAuthValidator"/>s.
/// </summary>
abstract class BaseOAuthValidator : IOAuthValidator
{
/// <inheritdoc />
public abstract OAuthProvider Provider { get; }
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="BaseOAuthValidator"/>.
/// </summary>
protected ILogger<BaseOAuthValidator> Logger { get; }
/// <summary>
/// The <see cref="OAuthConfiguration"/> for the <see cref="BaseOAuthValidator"/>.
/// </summary>
protected OAuthConfiguration OAuthConfiguration { get; }
/// <summary>
/// The <see cref="IHttpClientFactory"/> for the <see cref="BaseOAuthValidator"/>.
/// </summary>
readonly IHttpClientFactory httpClientFactory;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="BaseOAuthValidator"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// Gets <see cref="JsonSerializerSettings"/> that should be used.
/// </summary>
/// <returns>A new <see cref="JsonSerializerSettings"/> <see cref="object"/>.</returns>
protected static JsonSerializerSettings SerializerSettings() => new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
};
/// <summary>
/// Initializes a new instance of the <see cref="GenericOAuthValidator"/> <see langword="class"/>.
/// </summary>
/// <param name="httpClientFactory">The value of <see cref="httpClientFactory"/>.</param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
/// <param name="oAuthConfiguration">The value of <see cref="OAuthConfiguration"/>.</param>
public BaseOAuthValidator(
IHttpClientFactory httpClientFactory,
IAssemblyInformationProvider assemblyInformationProvider,
ILogger<BaseOAuthValidator> logger,
OAuthConfiguration oAuthConfiguration)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
OAuthConfiguration = oAuthConfiguration ?? throw new ArgumentNullException(nameof(oAuthConfiguration));
}
/// <inheritdoc />
public abstract Task<string> GetClientId(CancellationToken cancellationToken);
/// <inheritdoc />
public abstract Task<string> ValidateResponseCode(string code, CancellationToken cancellationToken);
/// <summary>
/// Create a new configured <see cref="HttpClient"/>.
/// </summary>
/// <returns>A new configured <see cref="HttpClient"/>.</returns>
protected HttpClient CreateHttpClient()
{
var httpClient = httpClientFactory.CreateClient();
try
{
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
httpClient.DefaultRequestHeaders.UserAgent.Add(assemblyInformationProvider.ProductInfoHeaderValue);
return httpClient;
}
catch
{
httpClient.Dispose();
throw;
}
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.Extensions.Logging;
using System;
using System.Net.Http;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// OAuth validator for Discord.
/// </summary>
sealed class DiscordOAuthValidator : GenericOAuthValidator
{
/// <summary>
/// Initializes a new instance of the <see cref="DiscordOAuthValidator"/> <see langword="class"/>.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for the <see cref="GenericOAuthValidator"/>.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> for the <see cref="GenericOAuthValidator"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="GenericOAuthValidator"/>.</param>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> for the <see cref="GenericOAuthValidator"/>.</param>
public DiscordOAuthValidator(
IHttpClientFactory httpClientFactory,
IAssemblyInformationProvider assemblyInformationProvider,
ILogger<DiscordOAuthValidator> logger,
OAuthConfiguration oAuthConfiguration)
: base(httpClientFactory, assemblyInformationProvider, logger, oAuthConfiguration)
{
}
/// <inheritdoc />
public override OAuthProvider Provider => OAuthProvider.Discord;
/// <inheritdoc />
protected override Uri TokenUrl => new Uri("https://discord.com/api/oauth2/token");
/// <inheritdoc />
protected override Uri UserInformationUrl => new Uri("https://discord.com/api/users/@me");
/// <inheritdoc />
protected override OAuthTokenRequest CreateTokenRequest(string code) => new DiscordTokenRequest(OAuthConfiguration, code);
/// <inheritdoc />
protected override string DecodeTokenPayload(dynamic responseJson) => responseJson.access_token;
/// <inheritdoc />
protected override string DecodeUserInformationPayload(dynamic responseJson) => responseJson.id;
}
}
@@ -0,0 +1,33 @@
using Tgstation.Server.Host.Configuration;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// <see cref="OAuthTokenRequest"/> for Discord.
/// </summary>
/// <remarks>See https://discord.com/developers/docs/topics/oauth2</remarks>
sealed class DiscordTokenRequest : OAuthTokenRequest
{
/// <summary>
/// The 'grant_type' field.
/// </summary>
public string GrantType { get; }
/// <summary>
/// The 'scope' field.
/// </summary>
public string Scope { get; }
/// <summary>
/// Initializes a new instance of the <see cref="DiscordTokenRequest"/> <see langword="class"/>.
/// </summary>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> for the <see cref="OAuthTokenRequest"/>.</param>
/// <param name="code">The OAuth code for the <see cref="OAuthTokenRequest"/>.</param>
public DiscordTokenRequest(OAuthConfiguration oAuthConfiguration, string code)
: base(oAuthConfiguration, code)
{
GrantType = "authorization_code";
Scope = "identify";
}
}
}
@@ -0,0 +1,124 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// <see cref="IOAuthValidator"/> for generic OAuth2 endpoints.
/// </summary>
abstract class GenericOAuthValidator : BaseOAuthValidator
{
/// <summary>
/// <see cref="Uri"/> to <see cref="HttpMethod.Post"/> to to get the access token.
/// </summary>
protected abstract Uri TokenUrl { get; }
/// <summary>
/// <see cref="Uri"/> to <see cref="HttpMethod.Get"/> the user information payload from.
/// </summary>
protected abstract Uri UserInformationUrl { get; }
/// <summary>
/// Initializes a new instance of the <see cref="GenericOAuthValidator"/> <see langword="class"/>.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for the <see cref="BaseOAuthValidator"/></param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> for the <see cref="BaseOAuthValidator"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="BaseOAuthValidator"/></param>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> for the <see cref="BaseOAuthValidator"/>.</param>
public GenericOAuthValidator(
IHttpClientFactory httpClientFactory,
IAssemblyInformationProvider assemblyInformationProvider,
ILogger<GenericOAuthValidator> logger,
OAuthConfiguration oAuthConfiguration)
: base(
httpClientFactory,
assemblyInformationProvider,
logger,
oAuthConfiguration)
{
}
/// <summary>
/// Decode the token payload <paramref name="responseJson"/>.
/// </summary>
/// <param name="responseJson">The token payload <see cref="JObject"/>.</param>
/// <returns>The OAuth2 bearer access token on success, <see langword="null"/> otherwise.</returns>
protected abstract string DecodeTokenPayload(dynamic responseJson);
/// <summary>
/// Decode the user information payload <paramref name="responseJson"/>.
/// </summary>
/// <param name="responseJson">The user information payload <see cref="JObject"/>.</param>
/// <returns>The user ID on success, <see langword="null"/> otherwise.</returns>
protected abstract string DecodeUserInformationPayload(dynamic responseJson);
/// <summary>
/// Create the <see cref="OAuthTokenRequest"/> for a given <paramref name="code"/>
/// </summary>
/// <param name="code">The OAuth code from the browser.</param>
/// <returns>The <see cref="OAuthTokenRequest"/> to send to <see cref="TokenUrl"/>.</returns>
protected abstract OAuthTokenRequest CreateTokenRequest(string code);
/// <inheritdoc />
public override async Task<string> ValidateResponseCode(string code, CancellationToken cancellationToken)
{
using var httpClient = CreateHttpClient();
try
{
Logger.LogTrace("Validating response code...");
using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, TokenUrl);
var tokenRequestPayload = CreateTokenRequest(code);
// roundabout but it works
var tokenRequestJson = JsonConvert.SerializeObject(
tokenRequestPayload,
SerializerSettings());
var tokenRequestDictionary = JsonConvert.DeserializeObject<Dictionary<string, string>>(tokenRequestJson);
tokenRequest.Content = new FormUrlEncodedContent(tokenRequestDictionary);
var tokenResponse = await httpClient.SendAsync(tokenRequest, cancellationToken).ConfigureAwait(false);
tokenResponse.EnsureSuccessStatusCode();
var tokenResponsePayload = await tokenResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
var tokenResponseJson = JObject.Parse(tokenResponsePayload);
var accessToken = DecodeTokenPayload(tokenResponseJson);
if (accessToken == null)
return null;
Logger.LogTrace("Getting user details...");
using var userInformationRequest = new HttpRequestMessage(HttpMethod.Get, UserInformationUrl);
userInformationRequest.Headers.Authorization = new AuthenticationHeaderValue(
ApiHeaders.BearerAuthenticationScheme,
accessToken);
var userInformationResponse = await httpClient.SendAsync(userInformationRequest, cancellationToken).ConfigureAwait(false);
userInformationResponse.EnsureSuccessStatusCode();
var userInformationPayload = await userInformationResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
var userInformationJson = JObject.Parse(userInformationPayload);
return DecodeUserInformationPayload(userInformationJson);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error while completing OAuth handshake!");
return null;
}
}
/// <inheritdoc />
public override Task<string> GetClientId(CancellationToken cancellationToken) => Task.FromResult(OAuthConfiguration.ClientId);
}
}
@@ -0,0 +1,99 @@
using Microsoft.Extensions.Logging;
using Octokit;
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// <see cref="IOAuthValidator"/> for GitHub.
/// </summary>
sealed class GitHubOAuthValidator : IOAuthValidator
{
/// <inheritdoc />
public OAuthProvider Provider => OAuthProvider.GitHub;
/// <summary>
/// The <see cref="IGitHubClientFactory"/> for the <see cref="GitHubOAuthValidator"/>.
/// </summary>
readonly IGitHubClientFactory gitHubClientFactory;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="GitHubOAuthValidator"/>.
/// </summary>
readonly ILogger<GitHubOAuthValidator> logger;
/// <summary>
/// The <see cref="OAuthConfiguration"/> for the <see cref="GitHubOAuthValidator"/>.
/// </summary>
readonly OAuthConfiguration oAuthConfiguration;
/// <summary>
/// Initializes a new instance of the <see cref="GitHubOAuthValidator"/> <see langword="class"/>.
/// </summary>
/// <param name="gitHubClientFactory">The value of <see cref="gitHubClientFactory"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
/// <param name="oAuthConfiguration">The value of <see cref="oAuthConfiguration"/>.</param>
public GitHubOAuthValidator(
IGitHubClientFactory gitHubClientFactory,
ILogger<GitHubOAuthValidator> logger,
OAuthConfiguration oAuthConfiguration)
{
this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.oAuthConfiguration = oAuthConfiguration ?? throw new ArgumentNullException(nameof(oAuthConfiguration));
}
/// <inheritdoc />
public async Task<string> ValidateResponseCode(string code, CancellationToken cancellationToken)
{
if (code == null)
throw new ArgumentNullException(nameof(code));
var client = gitHubClientFactory.CreateClient();
try
{
logger.LogTrace("Validating response code...");
var response = await client
.Oauth
.CreateAccessToken(
new OauthTokenRequest(
oAuthConfiguration.ClientId,
oAuthConfiguration.ClientSecret,
code))
.ConfigureAwait(false);
var token = response.AccessToken;
if (token == null)
return null;
var authenticatedClient = gitHubClientFactory.CreateClient(token);
logger.LogTrace("Getting user details...");
var userDetails = await authenticatedClient
.User
.Current()
.ConfigureAwait(false);
return userDetails.Id.ToString(CultureInfo.InvariantCulture);
}
catch (RateLimitExceededException)
{
throw;
}
catch(ApiException ex)
{
logger.LogWarning(ex, "API error while completing OAuth handshake!");
return null;
}
}
/// <inheritdoc />
public Task<string> GetClientId(CancellationToken cancellationToken) => Task.FromResult(oAuthConfiguration.ClientId);
}
}
@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Contains <see cref="IOAuthValidator"/>s
/// </summary>
public interface IOAuthProviders
{
/// <summary>
/// Gets the <see cref="IOAuthValidator"/> for a given <paramref name="oAuthProvider"/>.
/// </summary>
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/> to get the validator for.</param>
/// <returns>The <see cref="IOAuthValidator"/> for <paramref name="oAuthProvider"/>.</returns>
IOAuthValidator GetValidator(OAuthProvider oAuthProvider);
/// <summary>
/// Gets a <see cref="Dictionary{TKey, TValue}"/> of the provider client IDs.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a anew <see cref="Dictionary{TKey, TValue}"/> of the active provider client IDs.</returns>
Task<Dictionary<OAuthProvider, string>> ClientIds(CancellationToken cancellationToken);
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Validates OAuth responses for a given <see cref="Provider"/>.
/// </summary>
public interface IOAuthValidator
{
/// <summary>
/// The <see cref="OAuthProvider"/> this validator is for.
/// </summary>
OAuthProvider Provider { get; }
/// <summary>
/// Gets the OAuth client ID of validator.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the client ID of the validator on success, <see langword="null"/> on failure.</returns>
Task<string> GetClientId(CancellationToken cancellationToken);
/// <summary>
/// Validate a given OAuth response <paramref name="code"/>.
/// </summary>
/// <param name="code">The OAuth response string from web application.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="null"/> if authentication failed, <see cref="global::System.UInt64.MaxValue"/> if a rate limit occurred, and the validated <see cref="OAuthConnection.ExternalUserId"/> otherwise.</returns>
Task<string> ValidateResponseCode(string code, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,91 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <inheritdoc />
sealed class OAuthProviders : IOAuthProviders
{
/// <summary>
/// The <see cref="IReadOnlyCollection{T}"/> of <see cref="IOAuthValidator"/>s.
/// </summary>
readonly IReadOnlyCollection<IOAuthValidator> validators;
/// <summary>
/// Initializes a new instance of the <see cref="OAuthProviders"/> <see langword="class"/>.
/// </summary>
/// <param name="gitHubClientFactory">The <see cref="IGitHubClientFactory"/> to use.</param>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> to use.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> to use.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/> to use.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="SecurityConfiguration"/> to use.</param>
public OAuthProviders(
IGitHubClientFactory gitHubClientFactory,
IHttpClientFactory httpClientFactory,
IAssemblyInformationProvider assemblyInformationProvider,
ILoggerFactory loggerFactory,
IOptions<SecurityConfiguration> securityConfigurationOptions)
{
if (loggerFactory == null)
throw new ArgumentNullException(nameof(loggerFactory));
var securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
var validatorsBuilder = new List<IOAuthValidator>();
if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.GitHub, out var gitHubConfig))
validatorsBuilder.Add(
new GitHubOAuthValidator(
gitHubClientFactory,
loggerFactory.CreateLogger<GitHubOAuthValidator>(),
gitHubConfig));
if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.Discord, out var discordConfig))
validatorsBuilder.Add(
new DiscordOAuthValidator(
httpClientFactory,
assemblyInformationProvider,
loggerFactory.CreateLogger<DiscordOAuthValidator>(),
discordConfig));
if(securityConfiguration.OAuth.TryGetValue(OAuthProvider.TGForums, out var tgConfig))
validatorsBuilder.Add(
new TGForumsOAuthValidator(
httpClientFactory,
assemblyInformationProvider,
loggerFactory.CreateLogger<TGForumsOAuthValidator>(),
tgConfig));
validators = validatorsBuilder;
}
/// <inheritdoc />
public IOAuthValidator GetValidator(OAuthProvider oAuthProvider) => validators.FirstOrDefault(x => x.Provider == oAuthProvider);
/// <inheritdoc />
public async Task<Dictionary<OAuthProvider, string>> ClientIds(CancellationToken cancellationToken)
{
var providersAndTasks = validators.ToDictionary(
x => x.Provider,
x => x.GetClientId(cancellationToken));
await Task.WhenAll(providersAndTasks.Values).ConfigureAwait(false);
return providersAndTasks
.Where(x => x.Value.Result != null)
.ToDictionary(
x => x.Key,
x => x.Value.Result);
}
}
}
@@ -0,0 +1,27 @@
using System;
using Tgstation.Server.Host.Configuration;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Generic OAuth token request.
/// </summary>
class OAuthTokenRequest : OAuthConfiguration
{
/// <summary>
/// The OAuth code.
/// </summary>
public string Code { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OAuthTokenRequest"/>
/// </summary>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> to build from.</param>
/// <param name="code">The OAuth code received from the browser.</param>
public OAuthTokenRequest(OAuthConfiguration oAuthConfiguration, string code)
: base(oAuthConfiguration)
{
Code = code ?? throw new ArgumentNullException(nameof(code));
}
}
}
@@ -0,0 +1,23 @@
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Base <see langword="class"/> for tgstation forum responses.
/// </summary>
abstract class TGBaseResponse
{
/// <summary>
/// Expected value of <see cref="Status"/>.
/// </summary>
public const string OkStatus = "OK";
/// <summary>
/// The response status.
/// </summary>
public string Status { get; set; }
/// <summary>
/// The response error, if any.
/// </summary>
public string Error { get; set; }
}
}
@@ -0,0 +1,18 @@
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Response when creating a tgstation forums session.
/// </summary>
sealed class TGCreateSessionResponse : TGBaseResponse
{
/// <summary>
/// The session's private token. Similar to OAuth authorization response code.
/// </summary>
public string SessionPrivateToken { get; set; }
/// <summary>
/// The session's public token. Barely similar to OAuth client ID.
/// </summary>
public string SessionPublicToken { get; set; }
}
}
@@ -0,0 +1,140 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// <see cref="IOAuthValidator"/> for /tg/ forums.
/// </summary>
sealed class TGForumsOAuthValidator : BaseOAuthValidator
{
/// <summary>
/// Amount of minutes until unused sessions that were created are forgotten.
/// </summary>
const uint SessionRetentionMinutes = 10;
/// <inheritdoc />
public override OAuthProvider Provider => OAuthProvider.TGForums;
/// <summary>
/// The active session.
/// </summary>
readonly List<Tuple<TGCreateSessionResponse, DateTimeOffset>> sessions;
/// <summary>
/// Initializes a new instance of the <see cref="TGForumsOAuthValidator"/> <see langword="class"/>.
/// </summary>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for the <see cref="BaseOAuthValidator"/></param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> for the <see cref="BaseOAuthValidator"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="BaseOAuthValidator"/></param>
/// <param name="oAuthConfiguration">The <see cref="OAuthConfiguration"/> for the <see cref="BaseOAuthValidator"/>.</param>
public TGForumsOAuthValidator(
IHttpClientFactory httpClientFactory,
IAssemblyInformationProvider assemblyInformationProvider,
ILogger<TGForumsOAuthValidator> logger,
OAuthConfiguration oAuthConfiguration)
: base(
httpClientFactory,
assemblyInformationProvider,
logger,
oAuthConfiguration)
{
sessions = new List<Tuple<TGCreateSessionResponse, DateTimeOffset>>();
}
/// <inheritdoc />
public override async Task<string> GetClientId(CancellationToken cancellationToken)
{
var expiredSessions = sessions.RemoveAll(x => x.Item2.AddMinutes(SessionRetentionMinutes) < DateTimeOffset.Now);
if (expiredSessions > 0)
Logger.LogTrace("Expired {0} sessions", expiredSessions);
Logger.LogTrace("Creating new session...");
try
{
UriBuilder builder = new UriBuilder("https://tgstation13.org/phpBB/oauth_create_session.php")
{
Query = $"site_private_token={HttpUtility.UrlEncode(Convert.ToBase64String(Encoding.UTF8.GetBytes(OAuthConfiguration.ClientSecret)))}&return_uri={HttpUtility.UrlEncode(OAuthConfiguration.ClientId)}"
};
using var request = new HttpRequestMessage(HttpMethod.Get, builder.Uri);
using var httpClient = CreateHttpClient();
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var newSession = JsonConvert.DeserializeObject<TGCreateSessionResponse>(json, SerializerSettings());
if (newSession.Status != TGBaseResponse.OkStatus)
{
Logger.LogWarning("Invalid status from /tg/ API! Status: {0}, Error: {1}", newSession.Status, newSession.Error);
return null;
}
sessions.Add(Tuple.Create(newSession, DateTimeOffset.Now));
return newSession.SessionPublicToken;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to create TG Forums session!");
return null;
}
}
/// <inheritdoc />
public override async Task<string> ValidateResponseCode(string code, CancellationToken cancellationToken)
{
try
{
var sessionTuple = sessions.FirstOrDefault(x => x.Item1.SessionPublicToken == code);
if(sessionTuple == null)
{
Logger.LogWarning("No known session with this code active!");
return null;
}
Logger.LogTrace("Validating session...");
UriBuilder builder = new UriBuilder("https://tgstation13.org/phpBB/oauth_get_session_info.php")
{
Query = $"site_private_token={HttpUtility.UrlEncode(Convert.ToBase64String(Encoding.UTF8.GetBytes(OAuthConfiguration.ClientSecret)))}&session_private_token={HttpUtility.UrlEncode(sessionTuple.Item1.SessionPrivateToken)}"
};
using var request = new HttpRequestMessage(HttpMethod.Get, builder.Uri);
using var httpClient = CreateHttpClient();
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var sessionInfo = JsonConvert.DeserializeObject<TGGetSessionInfoResponse>(json, SerializerSettings());
if (sessionInfo.Status != TGBaseResponse.OkStatus)
{
Logger.LogWarning("Invalid status from /tg/ API! Status: {0}, Error: {1}", sessionInfo.Status, sessionInfo.Error);
return null;
}
sessions.Remove(sessionTuple);
return sessionInfo.PhpbbUsername;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to create TG Forums session!");
return null;
}
}
}
}
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Host.Security.OAuth
{
/// <summary>
/// Response when getting tgstation forum user's info.
/// </summary>
sealed class TGGetSessionInfoResponse : TGBaseResponse
{
/// <summary>
/// The user's forum account name.
/// </summary>
public string PhpbbUsername { get; set; }
}
}
+3 -1
View File
@@ -7,4 +7,6 @@
- [IIdentityCache](./IIdentityCache.cs) and [implementation](./IdentityCache.cs) is used to store `ISystemIdentity`s for the duration of their associated tokens as [IdentityCacheObject](./IdentityCacheObject.cs)s.
- [ITokenFactory](./ITokenFactory.cs) and [implementation](./TokenFactory.cs) is used to generate the Json Web Token for a session after a user successfully authenticates.
- [ISystemIdentity](./ISystemIdentity.cs)s represent a logon session with the operating system for a given user. It contains a method to run code under the security context of said user.
- [ISystemIdentityFactory](./ISystemIdentityFactory.cs) is used to create `ISystemIdentity`s by attempting to log the user in with the OS with a given username and password.
- [ISystemIdentityFactory](./ISystemIdentityFactory.cs) is used to create `ISystemIdentity`s by attempting to log the user in with the OS with a given username and password.
- [OAuth](./OAuth) contains classes related to OAuth 2.0 authentication
@@ -76,7 +76,7 @@ namespace Tgstation.Server.Host.Security
}
/// <inheritdoc />
public async Task<Token> CreateToken(Models.User user, CancellationToken cancellationToken)
public async Task<Token> CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
@@ -93,7 +93,9 @@ namespace Tgstation.Server.Host.Security
if (nowUnix == lpuUnix)
await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
var expiry = now.AddMinutes(securityConfiguration.TokenExpiryMinutes);
var expiry = now.AddMinutes(oAuth
? securityConfiguration.OAuthTokenExpiryMinutes
: securityConfiguration.TokenExpiryMinutes);
var claims = new Claim[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.Value.ToString(CultureInfo.InvariantCulture)),
@@ -1,4 +1,4 @@
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Setup
@@ -18,6 +18,11 @@ namespace Tgstation.Server.Host.Setup
/// </summary>
DatabaseConfiguration DatabaseConfiguration { get; }
/// <summary>
/// The <see cref="Configuration.SecurityConfiguration"/>.
/// </summary>
SecurityConfiguration SecurityConfiguration { get; }
/// <summary>
/// The <see cref="Configuration.FileLoggingConfiguration"/>.
/// </summary>
@@ -18,6 +18,9 @@ namespace Tgstation.Server.Host.Setup
/// <inheritdoc />
public DatabaseConfiguration DatabaseConfiguration => databaseConfigurationOptions.Value;
/// <inheritdoc />
public SecurityConfiguration SecurityConfiguration => securityConfigurationOptions.Value;
/// <inheritdoc />
public FileLoggingConfiguration FileLoggingConfiguration => fileLoggingConfigurationOptions.Value;
@@ -34,6 +37,11 @@ namespace Tgstation.Server.Host.Setup
/// </summary>
readonly IOptions<DatabaseConfiguration> databaseConfigurationOptions;
/// <summary>
/// Backing <see cref="IOptions{TOptions}"/> for <see cref="SecurityConfiguration"/>.
/// </summary>
readonly IOptions<SecurityConfiguration> securityConfigurationOptions;
/// <summary>
/// Backing <see cref="IOptions{TOptions}"/> for <see cref="FileLoggingConfiguration"/>.
/// </summary>
@@ -46,12 +54,14 @@ namespace Tgstation.Server.Host.Setup
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/> used to create <see cref="Logger"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="GeneralConfiguration"/>.</param>
/// <param name="databaseConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="DatabaseConfiguration"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="SecurityConfiguration"/>.</param>
/// <param name="fileLoggingConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="FileLoggingConfiguration"/>.</param>
public PostSetupServices(
IPlatformIdentifier platformIdentifier,
ILoggerFactory loggerFactory,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<DatabaseConfiguration> databaseConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions,
IOptions<FileLoggingConfiguration> fileLoggingConfigurationOptions)
{
PlatformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
@@ -61,6 +71,7 @@ namespace Tgstation.Server.Host.Setup
Logger = loggerFactory.CreateLogger<TLoggerType>();
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
this.databaseConfigurationOptions = databaseConfigurationOptions ?? throw new ArgumentNullException(nameof(databaseConfigurationOptions));
this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
this.fileLoggingConfigurationOptions = fileLoggingConfigurationOptions ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
}
}
@@ -62,6 +62,7 @@ namespace Tgstation.Server.Host.Setup
services.UseStandardConfig<GeneralConfiguration>(Configuration);
services.UseStandardConfig<DatabaseConfiguration>(Configuration);
services.UseStandardConfig<SecurityConfiguration>(Configuration);
services.UseStandardConfig<FileLoggingConfiguration>(Configuration);
ConfigureHostedService(services);
@@ -1,4 +1,5 @@
using System;
using System.Net.Http.Headers;
using System.Reflection;
using Tgstation.Server.Api;
@@ -22,6 +23,11 @@ namespace Tgstation.Server.Host.System
/// <inheritdoc />
public string VersionString { get; }
/// <inheritdoc />
public ProductInfoHeaderValue ProductInfoHeaderValue => new ProductInfoHeaderValue(
VersionPrefix,
Version.ToString());
/// <summary>
/// Initializes a new instance of the <see cref="AssemblyInformationProvider"/> <see langword="class"/>.
/// </summary>
@@ -1,4 +1,5 @@
using System;
using System.Net.Http.Headers;
using System.Reflection;
namespace Tgstation.Server.Host.System
@@ -32,5 +33,10 @@ namespace Tgstation.Server.Host.System
/// The version of the assembly.
/// </summary>
Version Version { get; }
/// <summary>
/// The <see cref="ProductInfoHeaderValue"/> for the assembly.
/// </summary>
ProductInfoHeaderValue ProductInfoHeaderValue { get; }
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<Import Project="../../build/Version.props" />
<PropertyGroup>
@@ -66,20 +66,20 @@
<PackageReference Include="Cyberboss.SmartIrc4net.Standard" Version="0.4.6" />
<PackageReference Include="Discord.Net.WebSocket" Version="2.2.0" />
<PackageReference Include="LibGit2Sharp" Version="0.27.0-preview-0034" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.7" />
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="3.3.0">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.10" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.10" />
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="3.3.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.7">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -87,7 +87,7 @@
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.4" />
<PackageReference Include="Octokit" Version="0.48.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.2.4" />
<!-- If this is updated, be sure to update the reference in the README.md -->
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" />
@@ -97,13 +97,13 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.5.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="5.5.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="5.6.3" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.2" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="4.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.7.1" />
<PackageReference Include="System.Management" Version="4.7.0" />
<PackageReference Include="Wangkanai.Detection.Browser" Version="2.0.0" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="5.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />
<PackageReference Include="System.Management" Version="5.0.0" />
<PackageReference Include="Wangkanai.Detection.Browser" Version="2.0.1" />
<PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="3.0.57" />
</ItemGroup>
+6 -1
View File
@@ -54,6 +54,11 @@
"TokenExpiryMinutes": 15,
"TokenClockSkewMinutes": 1,
"TokenSigningKeyByteAmount": 256,
"CustomTokenSigningKeyBase64": null
"CustomTokenSigningKeyBase64": null,
"OAuth": {
"GitHub": null,
"Discord": null,
"TGForums": null
}
}
}
@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Net.Http.Headers;
using System.Net.Mime;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Api.Tests
{
@@ -21,6 +22,7 @@ namespace Tgstation.Server.Api.Tests
Assert.ThrowsException<ArgumentNullException>(() => new ApiHeaders(null, null));
Assert.ThrowsException<ArgumentNullException>(() => new ApiHeaders(productHeaderValue, null));
var headers = new ApiHeaders(productHeaderValue, String.Empty);
headers = new ApiHeaders(productHeaderValue, String.Empty, OAuthProvider.GitHub);
}
[TestMethod]
@@ -1,7 +1,8 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Octokit;
using System;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Tgstation.Server.Host.System;
@@ -17,8 +18,7 @@ namespace Tgstation.Server.Host.Core.Tests
public async Task TestCreateBasicClient()
{
var mockApp = new Mock<IAssemblyInformationProvider>();
mockApp.SetupGet(x => x.Version).Returns(new Version()).Verifiable();
mockApp.SetupGet(x => x.VersionPrefix).Returns("TGSTests").Verifiable();
mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable();
var factory = new GitHubClientFactory(mockApp.Object);
@@ -36,8 +36,7 @@ namespace Tgstation.Server.Host.Core.Tests
public async Task TestCreateTokenClient()
{
var mockApp = new Mock<IAssemblyInformationProvider>();
mockApp.SetupGet(x => x.Version).Returns(new Version()).Verifiable();
mockApp.SetupGet(x => x.VersionPrefix).Returns("TGSTests").Verifiable();
mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable();
var factory = new GitHubClientFactory(mockApp.Object);
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Newtonsoft.Json;
using System;
using System.Diagnostics;
using System.IO;
@@ -12,6 +13,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Net.Sockets;
using System.Reflection;
using System.Threading;
@@ -37,9 +39,9 @@ namespace Tgstation.Server.Tests
readonly IServerClientFactory clientFactory = new ServerClientFactory(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
[TestMethod]
public async Task TestUpdateProtocol()
public async Task TestUpdateProtocolAndDisabledOAuth()
{
using var server = new TestingServer();
using var server = new TestingServer(false);
using var serverCts = new CancellationTokenSource();
var cancellationToken = serverCts.Token;
var serverTask = server.Run(cancellationToken);
@@ -47,11 +49,30 @@ namespace Tgstation.Server.Tests
{
var testUpdateVersion = new Version(4, 3, 0);
using (var adminClient = await CreateAdminClient(server.Url, cancellationToken))
{
// Disabled OAuth test
using (var httpClient = new HttpClient())
using (var request = new HttpRequestMessage(HttpMethod.Post, server.Url.ToString()))
{
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.Token.Bearer);
request.Headers.Add(ApiHeaders.OAuthProviderHeader, OAuthProvider.GitHub.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
Assert.AreEqual(ErrorCode.OAuthProviderDisabled, message.ErrorCode);
}
//attempt to update to stable
await adminClient.Administration.Update(new Administration
{
NewVersion = testUpdateVersion
}, cancellationToken).ConfigureAwait(false);
}
//wait up to 3 minutes for the dl and install
await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(3), cancellationToken)).ConfigureAwait(false);
@@ -207,7 +228,7 @@ namespace Tgstation.Server.Tests
Assert.Inconclusive("Cannot run server test because DreamDaemon will not start headless while the BYOND pager is running!");
}
using var server = new TestingServer();
using var server = new TestingServer(true);
const int MaximumTestMinutes = 20;
using var hardTimeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(MaximumTestMinutes));
@@ -450,7 +471,7 @@ namespace Tgstation.Server.Tests
[TestMethod]
public async Task TestRepoParentLookup()
{
using var testingServer = new TestingServer();
using var testingServer = new TestingServer(false);
LibGit2Sharp.Repository.Clone("https://github.com/Cyberboss/test", testingServer.Directory);
var libGit2Repo = new LibGit2Sharp.Repository(testingServer.Directory);
using var repo = new Host.Components.Repository.Repository(
+65 -12
View File
@@ -1,5 +1,6 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -56,12 +57,12 @@ namespace Tgstation.Server.Tests
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0");
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode);
var message = JsonConvert.DeserializeObject<ServerInformation>(content);
Assert.AreEqual(ApiHeaders.Version, message.ApiVersion);
}
using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()))
@@ -70,7 +71,21 @@ namespace Tgstation.Server.Tests
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0");
request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode);
}
using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Administration.Substring(1)))
{
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0");
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
@@ -83,8 +98,8 @@ namespace Tgstation.Server.Tests
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0");
request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
request.Content = new StringContent(
"{ newVersion: 1234 }",
Encoding.UTF8,
@@ -101,7 +116,7 @@ namespace Tgstation.Server.Tests
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0");
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
}
@@ -111,14 +126,29 @@ namespace Tgstation.Server.Tests
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0");
request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token);
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
Assert.AreEqual(ErrorCode.InstanceHeaderRequired, message.ErrorCode);
}
using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString()))
{
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, token);
request.Headers.Add(ApiHeaders.OAuthProviderHeader, "FakeProvider");
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var message = JsonConvert.DeserializeObject<ErrorMessage>(content);
Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode);
}
}
async Task TestServerInformation(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken)
@@ -140,12 +170,35 @@ namespace Tgstation.Server.Tests
};
var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken);
await Assert.ThrowsExceptionAsync<UnauthorizedException>(() => badClient.Version(cancellationToken)).ConfigureAwait(false);
await Assert.ThrowsExceptionAsync<UnauthorizedException>(() => badClient.Administration.Read(cancellationToken)).ConfigureAwait(false);
}
async Task TestOAuthFails(IServerClient serverClient, CancellationToken cancellationToken)
{
var url = serverClient.Url;
var token = serverClient.Token.Bearer;
// check that 400s are returned appropriately
using var httpClient = new HttpClient();
// just hitting each type of oauth provider for coverage
foreach (var I in Enum.GetValues(typeof(OAuthProvider)))
using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString()))
{
request.Headers.Accept.Clear();
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, token);
request.Headers.Add(ApiHeaders.OAuthProviderHeader, I.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
public Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken)
=> Task.WhenAll(
TestRequestValidation(serverClient, cancellationToken),
TestOAuthFails(serverClient, cancellationToken),
TestServerInformation(clientFactory, serverClient, cancellationToken));
}
}
+13 -1
View File
@@ -6,6 +6,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
@@ -31,7 +32,7 @@ namespace Tgstation.Server.Tests
IServer realServer;
public TestingServer()
public TestingServer(bool enableOAuth)
{
Directory = Environment.GetEnvironmentVariable("TGS4_TEST_TEMP_DIRECTORY");
if (String.IsNullOrWhiteSpace(Directory))
@@ -86,6 +87,17 @@ namespace Tgstation.Server.Tests
"General:ByondTopicTimeout=3000"
};
// enable all oauth providers
if (enableOAuth)
foreach (var I in Enum.GetValues(typeof(OAuthProvider)))
{
args.Add($"Security:OAuth:{I}:ClientId=Fake");
args.Add($"Security:OAuth:{I}:ClientSecret=Faker");
}
// SPECIFICALLY DELETE THE DEV APPSETTINGS, WE DON'T WANT IT IN THE WAY
File.Delete("appsettings.Development.json");
if (!String.IsNullOrEmpty(gitHubAccessToken))
args.Add(String.Format(CultureInfo.InvariantCulture, "General:GitHubAccessToken={0}", gitHubAccessToken));
+2 -2
View File
@@ -1,4 +1,4 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -29,7 +29,7 @@ namespace Tgstation.Server.Tests
async Task BasicTests(CancellationToken cancellationToken)
{
var user = await this.client.Read(cancellationToken).ConfigureAwait(false);
var user = await client.Read(cancellationToken).ConfigureAwait(false);
Assert.IsNotNull(user);
Assert.AreEqual("Admin", user.Name);
Assert.IsNull(user.SystemIdentifier);