diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c05ad9d61..96eb6e3108 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -189,6 +189,20 @@ We have a script to do this. 1. Run `dotnet ef migrations add SL --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. diff --git a/README.md b/README.md index d77e02c804..39da29f3a4 100644 --- a/README.md +++ b/README.md @@ -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:OAuth`: Sets the OAuth client ID and secret for a given ``. 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 diff --git a/build/Version.props b/build/Version.props index 220cd98461..6898cf6b0c 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,10 +3,10 @@ - 4.6.0 - 2.1.1 - 7.4.0 - 8.4.0 + 4.7.0 + 2.2.0 + 8.0.0 + 9.0.0 5.2.8 1.1.0 1.1.1 diff --git a/docs/API.dox b/docs/API.dox index e4320e103b..6beed23239 100644 --- a/docs/API.dox +++ b/docs/API.dox @@ -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": "..." +} +@endcode + +Other fields may be present in the Version model but should be ignored. See a description of these version numbers here. + @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 `` +- Authorization:Basic `` 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 Json Web Token. 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 `` +- Authorization:Bearer `` 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: + +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": "..." -} -@endcode - -Other fields may be present in the Version model but should be ignored. See a description of these version numbers here. - @section api_admin Server-wide Administrative Actions You can retrieve server update information with this diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index cecfa88a5a..9ec9c575b1 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -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 { /// - /// The header key + /// The header key. /// public const string ApiVersionHeader = "Api"; /// - /// The header key + /// The header key. /// public const string InstanceIdHeader = "Instance"; /// - /// The JWT authentication header scheme + /// The header key. /// - public const string JwtAuthenticationScheme = "bearer"; + public const string OAuthProviderHeader = "OAuthProvider"; /// /// The JWT authentication header scheme /// - public const string BasicAuthenticationScheme = "basic"; + public const string BearerAuthenticationScheme = "Bearer"; + + /// + /// The JWT authentication header scheme + /// + public const string BasicAuthenticationScheme = "Basic"; + + /// + /// The JWT authentication header scheme + /// + public const string OAuthAuthenticationScheme = "OAuth"; /// /// The current @@ -47,7 +58,7 @@ namespace Tgstation.Server.Api public static readonly Version Version = AssemblyName.Version.Semver(); /// - /// The instance being accessed + /// The instance being accessed /// public long? InstanceId { get; set; } @@ -82,9 +93,14 @@ namespace Tgstation.Server.Api public string? Password { get; } /// - /// If the header uses password or JWT authentication + /// The the is for, if any. /// - public bool IsTokenAuthentication => Token != null; + public OAuthProvider? OAuthProvider { get; } + + /// + /// If the header uses password or TGS JWT authentication. + /// + public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue; /// /// Checks if a given is compatible with our own @@ -98,12 +114,15 @@ namespace Tgstation.Server.Api /// /// The value of /// The value of - public ApiHeaders(ProductHeaderValue userAgent, string token) : this(userAgent, token, null, null) + /// The value of . + 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; } /// @@ -126,8 +145,10 @@ namespace Tgstation.Server.Api /// Construct and validates from a set of /// /// The containing the + /// If a missing should be ignored. /// Thrown if the constitue invalid . - 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(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 /// /// Construct @@ -253,7 +289,7 @@ namespace Tgstation.Server.Api /// Set using the . This initially clears /// /// The to set - /// The instance for the request + /// The instance for the request 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()); diff --git a/src/Tgstation.Server.Api/HeaderTypes.cs b/src/Tgstation.Server.Api/HeaderTypes.cs index a1d0a6e17e..b45b6b0380 100644 --- a/src/Tgstation.Server.Api/HeaderTypes.cs +++ b/src/Tgstation.Server.Api/HeaderTypes.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Tgstation.Server.Api { @@ -24,13 +24,18 @@ namespace Tgstation.Server.Api Accept = 2, /// - /// Api header. + /// . /// Api = 4, /// /// /// - Authorization = 8 + Authorization = 8, + + /// + /// . + /// + OAuthProvider = 16, } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 700ac27969..4c08b12b30 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -64,10 +64,10 @@ namespace Tgstation.Server.Api.Models CannotChangeServerSuite, /// - /// A required GitHub API request failed. + /// A required remote API request failed. /// - [Description("A required GitHub request returned an API error!")] - GitHubApiError, + [Description("A required remote API request returned an error!")] + RemoteApiError, /// /// A server update was requested while another was in progress. @@ -582,5 +582,17 @@ namespace Tgstation.Server.Api.Models /// [Description("The requested port is either already in use by TGS or could not be allocated!")] PortNotAvailable, + + /// + /// Attempted to set for the admin user. + /// + [Description("The admin user cannot use OAuth connections!")] + AdminUserCannotOAuth, + + /// + /// Attempted to login with a disabled OAuth provider. + /// + [Description("The requested OAuth provider is disabled via configuration!")] + OAuthProviderDisabled, } } diff --git a/src/Tgstation.Server.Api/Models/OAuthConnection.cs b/src/Tgstation.Server.Api/Models/OAuthConnection.cs new file mode 100644 index 0000000000..d684a04e2e --- /dev/null +++ b/src/Tgstation.Server.Api/Models/OAuthConnection.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a valid OAuth connection. + /// + public class OAuthConnection + { + /// + /// The of the . + /// ] + [EnumDataType(typeof(OAuthProvider))] + public OAuthProvider Provider { get; set; } + + /// + /// The ID of the user in the . + /// + [Required] + [StringLength(Limits.MaximumIndexableStringLength)] + public string? ExternalUserId { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/OAuthProvider.cs b/src/Tgstation.Server.Api/Models/OAuthProvider.cs new file mode 100644 index 0000000000..8d5a9eb354 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/OAuthProvider.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Api.Models +{ + /// + /// List of OAuth providers supported by TGS + /// + public enum OAuthProvider + { + /// + /// https://github.com + /// + GitHub, + + /// + /// https://discord.com + /// + Discord, + + /// + /// https://tgstation13.org + /// + TGForums, + } +} diff --git a/src/Tgstation.Server.Api/Models/ServerInformation.cs b/src/Tgstation.Server.Api/Models/ServerInformation.cs index 26d9cbc0fd..a2d8958845 100644 --- a/src/Tgstation.Server.Api/Models/ServerInformation.cs +++ b/src/Tgstation.Server.Api/Models/ServerInformation.cs @@ -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. /// public Version? DMApiVersion { get; set; } + + /// + /// Map of to the server's associated client IDs for them. + /// + public IDictionary? OAuthProviderClientIds { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/User.cs b/src/Tgstation.Server.Api/Models/User.cs index 60ecb7225d..9ac62d975e 100644 --- a/src/Tgstation.Server.Api/Models/User.cs +++ b/src/Tgstation.Server.Api/Models/User.cs @@ -1,4 +1,6 @@ -namespace Tgstation.Server.Api.Models +using System.Collections.Generic; + +namespace Tgstation.Server.Api.Models { /// public class User : Internal.User @@ -17,5 +19,10 @@ /// The who created this /// public Internal.User? CreatedBy { get; set; } + + /// + /// List of s associated with the . + /// + public ICollection? OAuthConnections { get; set; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs index 02b984d7f0..73d9663d8f 100644 --- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs +++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs @@ -42,5 +42,10 @@ namespace Tgstation.Server.Api.Rights /// User can list and download s. /// DownloadLogs = 32, + + /// + /// User can modify their own . + /// + EditOwnOAuthConnections = 64, } } diff --git a/src/Tgstation.Server.Host/.config/dotnet-tools.json b/src/Tgstation.Server.Host/.config/dotnet-tools.json index 79d13146a3..03fa86ccf3 100644 --- a/src/Tgstation.Server.Host/.config/dotnet-tools.json +++ b/src/Tgstation.Server.Host/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "3.1.7", + "version": "3.1.10", "commands": [ "dotnet-ef" ] diff --git a/src/Tgstation.Server.Host/Configuration/OAuthConfiguration.cs b/src/Tgstation.Server.Host/Configuration/OAuthConfiguration.cs new file mode 100644 index 0000000000..b0dc12fc21 --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/OAuthConfiguration.cs @@ -0,0 +1,37 @@ +using System; + +namespace Tgstation.Server.Host.Configuration +{ + /// + /// OAuth options. + /// + class OAuthConfiguration + { + /// + /// The client ID. + /// + public string ClientId { get; set; } + + /// + /// The client secret. + /// + public string ClientSecret { get; set; } + + /// + /// Initializes a new instance of the . + /// + public OAuthConfiguration() { } + + /// + /// Initializes a new instance of the . + /// + /// The to copy settings from. + public OAuthConfiguration(OAuthConfiguration oAuthConfiguration) + { + if (oAuthConfiguration == null) + throw new ArgumentNullException(nameof(oAuthConfiguration)); + ClientId = oAuthConfiguration.ClientId; + ClientSecret = oAuthConfiguration.ClientSecret; + } + } +} diff --git a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs index c0101eeca9..1957a3b56d 100644 --- a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using Tgstation.Server.Api.Models; + namespace Tgstation.Server.Host.Configuration { /// @@ -15,6 +18,11 @@ namespace Tgstation.Server.Host.Configuration /// const uint DefaultTokenExpiryMinutes = 15; + /// + /// Default value of . + /// + const uint DefaultOAuthTokenExpiryMinutes = 60 * 24; // 1 day + /// /// Default value of . /// @@ -26,12 +34,12 @@ namespace Tgstation.Server.Host.Configuration const uint DefaultTokenSigningKeyByteAmount = 256; /// - /// Amount of minutes until generated s expire. + /// Amount of minutes until s generated from passwords expire. /// public uint TokenExpiryMinutes { get; set; } = DefaultTokenExpiryMinutes; /// - /// Amount of minutes to skew the clock for validation. + /// Amount of minutes to skew the clock for validation. /// public uint TokenClockSkewMinutes { get; set; } = DefaultTokenClockSkewMinutes; @@ -40,9 +48,19 @@ namespace Tgstation.Server.Host.Configuration /// public uint TokenSigningKeyByteCount { get; set; } = DefaultTokenSigningKeyByteAmount; + /// + /// Amount of minutes until s generated from OAuth logins expire. + /// + public uint OAuthTokenExpiryMinutes { get; set; } = DefaultOAuthTokenExpiryMinutes; + /// /// A custom token signing key. Overrides . /// public string CustomTokenSigningKeyBase64 { get; set; } + + /// + /// OAuth provider settings. + /// + public IDictionary OAuth { get; set; } } } diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index c35de94da2..a41fb7895c 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -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)); - } - /// /// Try to download and apply an update with a given . /// @@ -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 }); diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 90a5c5f448..0a772be319 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -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 /// The for the /// The value of /// The value of - public ApiController( + protected ApiController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger, @@ -115,6 +118,22 @@ namespace Tgstation.Server.Host.Controllers /// A with the given . protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); + /// + /// 429 response for a given . + /// + /// The that occurred. + /// A . + 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)); + } + /// /// Performs validation a request. /// @@ -126,13 +145,14 @@ namespace Tgstation.Server.Host.Controllers /// /// Response for missing/Invalid headers. /// + /// Whether or not errors due to missing should be thrown. /// The appropriate . - 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); } } diff --git a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs index 506d9cc12d..275acbe91a 100644 --- a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs +++ b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs @@ -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); } diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 234f5299f7..9d09033105 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -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 /// readonly IIdentityCache identityCache; + /// + /// The for the . + /// + readonly IOAuthProviders oAuthProviders; + /// /// The for the /// @@ -78,6 +87,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The value of + /// The value of . /// The value of /// The containing the value of . /// The containing the value of @@ -90,6 +100,7 @@ namespace Tgstation.Server.Host.Controllers ICryptographySuite cryptographySuite, IAssemblyInformationProvider assemblyInformationProvider, IIdentityCache identityCache, + IOAuthProviders oAuthProviders, IBrowserResolver browserResolver, IOptions generalConfigurationOptions, IOptions 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 /// /// Main page of the /// + /// The for the operation. /// - /// The of the if a properly authenticated API request, the web control panel if on a browser and enabled, otherwise. + /// A resuting in the containing of the if a properly authenticated API request, the web control panel if on a browser and enabled, otherwise. /// /// retrieved successfully. [HttpGet] - [TgsAuthorize] [AllowAnonymous] [ProducesResponseType(typeof(ServerInformation), 200)] - public IActionResult Home() + public async Task 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) + }); } /// @@ -154,40 +179,77 @@ namespace Tgstation.Server.Host.Controllers /// User logged in and generated successfully. /// User authentication failed. /// User authenticated but is disabled by an administrator. + /// OAuth authentication failed due to rate limiting. [HttpPost] [ProducesResponseType(typeof(Token), 200)] - #pragma warning disable CA1506 // TODO: Decomplexify + [ProducesResponseType(typeof(ErrorMessage), 429)] +#pragma warning disable CA1506 // TODO: Decomplexify public async Task 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 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 } } diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index d5926406e6..15c325389e 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -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); diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 88de87bbce..1cc8604d72 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -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; diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 485216faa4..19c25ad761 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -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() - }; + 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 /// updated successfully. /// Requested does not exist. [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)); } + + /// + /// Creates a new from a given . + /// + /// The to use as a template. + /// A new . + 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(), + OAuthConnections = model + .OAuthConnections + ?.Select(x => new Models.OAuthConnection + { + Provider = x.Provider, + ExternalUserId = x.ExternalUserId + }) + .ToList() + }; } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 206efd22b0..80d71e27c0 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -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(Configuration); services.UseStandardConfig(Configuration); - services.UseStandardConfig(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() - .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() + .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() where TContext : DatabaseContext { var configureAction = DatabaseContext.GetConfigureAction(); @@ -260,6 +268,7 @@ namespace Tgstation.Server.Host.Core // configure security services services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs b/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs index e2bc292b18..218a19bf4f 100644 --- a/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs +++ b/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs @@ -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 /// /// A new - 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)); /// public IGitHubClient CreateClient() => CreateBaseClient(); diff --git a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs index b928dd6f2a..2d5a07a408 100644 --- a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs @@ -176,7 +176,7 @@ namespace Tgstation.Server.Host.Core In = ParameterLocation.Header, Type = SecuritySchemeType.Http, Name = HeaderNames.Authorization, - Scheme = ApiHeaders.JwtAuthenticationScheme + Scheme = ApiHeaders.BearerAuthenticationScheme }); } diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index 85f00bd68a..1c0eb0b5ee 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -87,10 +87,15 @@ namespace Tgstation.Server.Host.Database public DbSet TestMerges { get; set; } /// - /// The s om the + /// The s in the /// public DbSet RevInfoTestMerges { get; set; } + /// + /// The s in the + /// + public DbSet OAuthConnections { get; set; } + /// /// The for the / foreign key. /// @@ -132,6 +137,9 @@ namespace Tgstation.Server.Host.Database /// IDatabaseCollection IDatabaseContext.ReattachInformations => reattachInformationsCollection; + /// + IDatabaseCollection IDatabaseContext.OAuthConnections => oAuthConnections; + /// /// Backing field for . /// @@ -192,6 +200,11 @@ namespace Tgstation.Server.Host.Database /// readonly IDatabaseCollection reattachInformationsCollection; + /// + /// Backing field for . + /// + readonly IDatabaseCollection oAuthConnections; + /// /// Gets the configure action for a given . /// @@ -216,7 +229,7 @@ namespace Tgstation.Server.Host.Database /// Construct a /// /// The for the . - public DatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + protected DatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions) { usersCollection = new DatabaseCollection(Users); instancesCollection = new DatabaseCollection(Instances); @@ -230,6 +243,7 @@ namespace Tgstation.Server.Host.Database revisionInformationsCollection = new DatabaseCollection(RevisionInformations); jobsCollection = new DatabaseCollection(Jobs); reattachInformationsCollection = new DatabaseCollection(ReattachInformations); + oAuthConnections = new DatabaseCollection(OAuthConnections); } /// @@ -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().HasIndex(x => new { x.Provider, x.ExternalUserId }).IsUnique(); modelBuilder.Entity().HasIndex(x => new { x.UserId, x.InstanceId }).IsUnique(); @@ -316,6 +333,7 @@ namespace Tgstation.Server.Host.Database } /// +#pragma warning disable CA1502 // Cyclomatic complexity public async Task SchemaDowngradeForServerVersion( ILogger 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 } } diff --git a/src/Tgstation.Server.Host/Database/IDatabaseContext.cs b/src/Tgstation.Server.Host/Database/IDatabaseContext.cs index b7679599cc..2057196e65 100644 --- a/src/Tgstation.Server.Host/Database/IDatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/IDatabaseContext.cs @@ -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 { /// - /// The s in the + /// The s in the . /// IDatabaseCollection Users { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection Instances { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection InstanceUsers { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection Jobs { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection CompileJobs { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection RevisionInformations { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection DreamMakerSettings { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection DreamDaemonSettings { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection ChatBots { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection ChatChannels { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection RepositorySettings { get; } /// - /// The for s + /// The for s. /// IDatabaseCollection ReattachInformations { get; } + /// + /// The for s. + /// + IDatabaseCollection OAuthConnections { get; } + /// /// Saves changes made to the /// diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..2a705bfa38 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.Designer.cs @@ -0,0 +1,822 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("decimal(20,0)"); + + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("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 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.cs new file mode 100644 index 0000000000..f965ae37de --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for MSSQL. + /// + public partial class MSAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..9b2bac237d --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.Designer.cs @@ -0,0 +1,811 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); + + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); + + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("bigint unsigned"); + + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); + + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessToken") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Comment") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetime(6)"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("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 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.cs new file mode 100644 index 0000000000..d2741b785b --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for MYSQL. + /// + public partial class MYAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..48cbdcf3a7 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.Designer.cs @@ -0,0 +1,819 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); + + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); + + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("numeric(20,0)"); + + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessToken") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("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 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.cs new file mode 100644 index 0000000000..b660fe83e0 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for PostgresSQL. + /// + public partial class PGAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..3a45f3b0a3 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.Designer.cs @@ -0,0 +1,810 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("IrcChannel") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); + + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondRights") + .HasColumnType("INTEGER"); + + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("InstanceUserRights") + .HasColumnType("INTEGER"); + + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CancelRight") + .HasColumnType("INTEGER"); + + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CancelledById") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorCode") + .HasColumnType("INTEGER"); + + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedById") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("MergedById") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.cs new file mode 100644 index 0000000000..9c8cdfb567 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for SQLite. + /// + public partial class SLAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs index e16999c281..6859a6252b 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("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("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") diff --git a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs index b19d291ae9..5cfd13f687 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("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("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") diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs index 2c468c069a..8fba8e0ec6 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("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("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") diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs index de2eca1c74..c7ffe85f0f 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("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("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") diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs new file mode 100644 index 0000000000..590849a538 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -0,0 +1,26 @@ +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class OAuthConnection : Api.Models.OAuthConnection + { + /// + /// The row Id. + /// + public long Id { get; set; } + + /// + /// The owning . + /// + public User User { get; set; } + + /// + /// Convert the to it's API form. + /// + /// A new . + public Api.Models.OAuthConnection ToApi() => new Api.Models.OAuthConnection + { + Provider = Provider, + ExternalUserId = ExternalUserId + }; + } +} diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index ed1ec273aa..5d4783dcf0 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -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 /// public ICollection TestMerges { get; set; } + /// + /// The s made by the + /// + public ICollection OAuthConnections { get; set; } + /// /// Change a into a . /// diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index 9ee9ba309c..90177bb524 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -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 for a given /// /// The to create the token for. Must have the field available + /// Whether or not this is an OAuth login. /// The for the operation /// A resulting in a new - Task CreateToken(Models.User user, CancellationToken cancellationToken); + Task CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Security/OAuth/BaseOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/BaseOAuthValidator.cs new file mode 100644 index 0000000000..68cc17cf23 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/BaseOAuthValidator.cs @@ -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 +{ + /// + /// Base for s. + /// + abstract class BaseOAuthValidator : IOAuthValidator + { + /// + public abstract OAuthProvider Provider { get; } + + /// + /// The for the . + /// + protected ILogger Logger { get; } + + /// + /// The for the . + /// + protected OAuthConfiguration OAuthConfiguration { get; } + + /// + /// The for the . + /// + readonly IHttpClientFactory httpClientFactory; + + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// Gets that should be used. + /// + /// A new . + protected static JsonSerializerSettings SerializerSettings() => new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } + }; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public BaseOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger 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)); + } + + /// + public abstract Task GetClientId(CancellationToken cancellationToken); + + /// + public abstract Task ValidateResponseCode(string code, CancellationToken cancellationToken); + + /// + /// Create a new configured . + /// + /// A new configured . + 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; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs new file mode 100644 index 0000000000..f0fe8591f5 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs @@ -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 +{ + /// + /// OAuth validator for Discord. + /// + sealed class DiscordOAuthValidator : GenericOAuthValidator + { + /// + /// Initializes a new instance of the . + /// + /// The for the . + /// The for the . + /// The for the . + /// The for the . + public DiscordOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base(httpClientFactory, assemblyInformationProvider, logger, oAuthConfiguration) + { + } + + /// + public override OAuthProvider Provider => OAuthProvider.Discord; + + /// + protected override Uri TokenUrl => new Uri("https://discord.com/api/oauth2/token"); + + /// + protected override Uri UserInformationUrl => new Uri("https://discord.com/api/users/@me"); + + /// + protected override OAuthTokenRequest CreateTokenRequest(string code) => new DiscordTokenRequest(OAuthConfiguration, code); + + /// + protected override string DecodeTokenPayload(dynamic responseJson) => responseJson.access_token; + + /// + protected override string DecodeUserInformationPayload(dynamic responseJson) => responseJson.id; + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/DiscordTokenRequest.cs b/src/Tgstation.Server.Host/Security/OAuth/DiscordTokenRequest.cs new file mode 100644 index 0000000000..fdd6dcc393 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/DiscordTokenRequest.cs @@ -0,0 +1,33 @@ +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// for Discord. + /// + /// See https://discord.com/developers/docs/topics/oauth2 + sealed class DiscordTokenRequest : OAuthTokenRequest + { + /// + /// The 'grant_type' field. + /// + public string GrantType { get; } + + /// + /// The 'scope' field. + /// + public string Scope { get; } + + /// + /// Initializes a new instance of the . + /// + /// The for the . + /// The OAuth code for the . + public DiscordTokenRequest(OAuthConfiguration oAuthConfiguration, string code) + : base(oAuthConfiguration, code) + { + GrantType = "authorization_code"; + Scope = "identify"; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs new file mode 100644 index 0000000000..8deff22537 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -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 +{ + /// + /// for generic OAuth2 endpoints. + /// + abstract class GenericOAuthValidator : BaseOAuthValidator + { + /// + /// to to to get the access token. + /// + protected abstract Uri TokenUrl { get; } + + /// + /// to the user information payload from. + /// + protected abstract Uri UserInformationUrl { get; } + + /// + /// Initializes a new instance of the . + /// + /// The for the + /// The for the + /// The for the + /// The for the . + public GenericOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base( + httpClientFactory, + assemblyInformationProvider, + logger, + oAuthConfiguration) + { + } + + /// + /// Decode the token payload . + /// + /// The token payload . + /// The OAuth2 bearer access token on success, otherwise. + protected abstract string DecodeTokenPayload(dynamic responseJson); + + /// + /// Decode the user information payload . + /// + /// The user information payload . + /// The user ID on success, otherwise. + protected abstract string DecodeUserInformationPayload(dynamic responseJson); + + /// + /// Create the for a given + /// + /// The OAuth code from the browser. + /// The to send to . + protected abstract OAuthTokenRequest CreateTokenRequest(string code); + + /// + public override async Task 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>(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; + } + } + + /// + public override Task GetClientId(CancellationToken cancellationToken) => Task.FromResult(OAuthConfiguration.ClientId); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs new file mode 100644 index 0000000000..b4ee1354e6 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs @@ -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 +{ + /// + /// for GitHub. + /// + sealed class GitHubOAuthValidator : IOAuthValidator + { + /// + public OAuthProvider Provider => OAuthProvider.GitHub; + + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly OAuthConfiguration oAuthConfiguration; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + public GitHubOAuthValidator( + IGitHubClientFactory gitHubClientFactory, + ILogger 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)); + } + + /// + public async Task 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; + } + } + + /// + public Task GetClientId(CancellationToken cancellationToken) => Task.FromResult(oAuthConfiguration.ClientId); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs new file mode 100644 index 0000000000..66af75b5af --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs @@ -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 +{ + /// + /// Contains s + /// + public interface IOAuthProviders + { + /// + /// Gets the for a given . + /// + /// The to get the validator for. + /// The for . + IOAuthValidator GetValidator(OAuthProvider oAuthProvider); + + /// + /// Gets a of the provider client IDs. + /// + /// The for the operation. + /// A resulting in a anew of the active provider client IDs. + Task> ClientIds(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs new file mode 100644 index 0000000000..be33dcb992 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Validates OAuth responses for a given . + /// + public interface IOAuthValidator + { + /// + /// The this validator is for. + /// + OAuthProvider Provider { get; } + + /// + /// Gets the OAuth client ID of validator. + /// + /// The for the operation. + /// A resulting in the client ID of the validator on success, on failure. + Task GetClientId(CancellationToken cancellationToken); + + /// + /// Validate a given OAuth response . + /// + /// The OAuth response string from web application. + /// The for the operation. + /// A resulting in if authentication failed, if a rate limit occurred, and the validated otherwise. + Task ValidateResponseCode(string code, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs new file mode 100644 index 0000000000..91be97f0e2 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs @@ -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 +{ + /// + sealed class OAuthProviders : IOAuthProviders + { + /// + /// The of s. + /// + readonly IReadOnlyCollection validators; + + /// + /// Initializes a new instance of the . + /// + /// The to use. + /// The to use. + /// The to use. + /// The to use. + /// The containing the to use. + public OAuthProviders( + IGitHubClientFactory gitHubClientFactory, + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILoggerFactory loggerFactory, + IOptions securityConfigurationOptions) + { + if (loggerFactory == null) + throw new ArgumentNullException(nameof(loggerFactory)); + + var securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions)); + + var validatorsBuilder = new List(); + + if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.GitHub, out var gitHubConfig)) + validatorsBuilder.Add( + new GitHubOAuthValidator( + gitHubClientFactory, + loggerFactory.CreateLogger(), + gitHubConfig)); + + if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.Discord, out var discordConfig)) + validatorsBuilder.Add( + new DiscordOAuthValidator( + httpClientFactory, + assemblyInformationProvider, + loggerFactory.CreateLogger(), + discordConfig)); + + if(securityConfiguration.OAuth.TryGetValue(OAuthProvider.TGForums, out var tgConfig)) + validatorsBuilder.Add( + new TGForumsOAuthValidator( + httpClientFactory, + assemblyInformationProvider, + loggerFactory.CreateLogger(), + tgConfig)); + + validators = validatorsBuilder; + } + + /// + public IOAuthValidator GetValidator(OAuthProvider oAuthProvider) => validators.FirstOrDefault(x => x.Provider == oAuthProvider); + + /// + public async Task> 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); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthTokenRequest.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthTokenRequest.cs new file mode 100644 index 0000000000..7f03023625 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthTokenRequest.cs @@ -0,0 +1,27 @@ +using System; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Generic OAuth token request. + /// + class OAuthTokenRequest : OAuthConfiguration + { + /// + /// The OAuth code. + /// + public string Code { get; } + + /// + /// Initializes a new instance of the + /// + /// The to build from. + /// The OAuth code received from the browser. + public OAuthTokenRequest(OAuthConfiguration oAuthConfiguration, string code) + : base(oAuthConfiguration) + { + Code = code ?? throw new ArgumentNullException(nameof(code)); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGBaseResponse.cs b/src/Tgstation.Server.Host/Security/OAuth/TGBaseResponse.cs new file mode 100644 index 0000000000..1f887179e3 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGBaseResponse.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Base for tgstation forum responses. + /// + abstract class TGBaseResponse + { + /// + /// Expected value of . + /// + public const string OkStatus = "OK"; + + /// + /// The response status. + /// + public string Status { get; set; } + + /// + /// The response error, if any. + /// + public string Error { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGCreateSessionResponse.cs b/src/Tgstation.Server.Host/Security/OAuth/TGCreateSessionResponse.cs new file mode 100644 index 0000000000..7d8d53face --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGCreateSessionResponse.cs @@ -0,0 +1,18 @@ +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Response when creating a tgstation forums session. + /// + sealed class TGCreateSessionResponse : TGBaseResponse + { + /// + /// The session's private token. Similar to OAuth authorization response code. + /// + public string SessionPrivateToken { get; set; } + + /// + /// The session's public token. Barely similar to OAuth client ID. + /// + public string SessionPublicToken { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGForumsOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/TGForumsOAuthValidator.cs new file mode 100644 index 0000000000..afdd635974 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGForumsOAuthValidator.cs @@ -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 +{ + /// + /// for /tg/ forums. + /// + sealed class TGForumsOAuthValidator : BaseOAuthValidator + { + /// + /// Amount of minutes until unused sessions that were created are forgotten. + /// + const uint SessionRetentionMinutes = 10; + + /// + public override OAuthProvider Provider => OAuthProvider.TGForums; + + /// + /// The active session. + /// + readonly List> sessions; + + /// + /// Initializes a new instance of the . + /// + /// The for the + /// The for the + /// The for the + /// The for the . + public TGForumsOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base( + httpClientFactory, + assemblyInformationProvider, + logger, + oAuthConfiguration) + { + sessions = new List>(); + } + + /// + public override async Task 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(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; + } + } + + /// + public override async Task 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(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; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGGetSessionInfoResponse.cs b/src/Tgstation.Server.Host/Security/OAuth/TGGetSessionInfoResponse.cs new file mode 100644 index 0000000000..89b834e769 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGGetSessionInfoResponse.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Response when getting tgstation forum user's info. + /// + sealed class TGGetSessionInfoResponse : TGBaseResponse + { + /// + /// The user's forum account name. + /// + public string PhpbbUsername { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index aea0dbf6cf..80f5afaccf 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -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. \ No newline at end of file +- [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 diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index 40a898abd5..2c9a86df45 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -76,7 +76,7 @@ namespace Tgstation.Server.Host.Security } /// - public async Task CreateToken(Models.User user, CancellationToken cancellationToken) + public async Task 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)), diff --git a/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs b/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs index acc04b6518..edc85dea40 100644 --- a/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs +++ b/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs @@ -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 /// DatabaseConfiguration DatabaseConfiguration { get; } + /// + /// The . + /// + SecurityConfiguration SecurityConfiguration { get; } + /// /// The . /// diff --git a/src/Tgstation.Server.Host/Setup/PostSetupServices.cs b/src/Tgstation.Server.Host/Setup/PostSetupServices.cs index f595e3ba65..1297381ef2 100644 --- a/src/Tgstation.Server.Host/Setup/PostSetupServices.cs +++ b/src/Tgstation.Server.Host/Setup/PostSetupServices.cs @@ -18,6 +18,9 @@ namespace Tgstation.Server.Host.Setup /// public DatabaseConfiguration DatabaseConfiguration => databaseConfigurationOptions.Value; + /// + public SecurityConfiguration SecurityConfiguration => securityConfigurationOptions.Value; + /// public FileLoggingConfiguration FileLoggingConfiguration => fileLoggingConfigurationOptions.Value; @@ -34,6 +37,11 @@ namespace Tgstation.Server.Host.Setup /// readonly IOptions databaseConfigurationOptions; + /// + /// Backing for . + /// + readonly IOptions securityConfigurationOptions; + /// /// Backing for . /// @@ -46,12 +54,14 @@ namespace Tgstation.Server.Host.Setup /// The used to create . /// The containing the value of . /// The containing the value of . + /// The containing the value of . /// The containing the value of . public PostSetupServices( IPlatformIdentifier platformIdentifier, ILoggerFactory loggerFactory, IOptions generalConfigurationOptions, IOptions databaseConfigurationOptions, + IOptions securityConfigurationOptions, IOptions fileLoggingConfigurationOptions) { PlatformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); @@ -61,6 +71,7 @@ namespace Tgstation.Server.Host.Setup Logger = loggerFactory.CreateLogger(); 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)); } } diff --git a/src/Tgstation.Server.Host/Setup/SetupApplication.cs b/src/Tgstation.Server.Host/Setup/SetupApplication.cs index 9f848efa8f..b6a04bd536 100644 --- a/src/Tgstation.Server.Host/Setup/SetupApplication.cs +++ b/src/Tgstation.Server.Host/Setup/SetupApplication.cs @@ -62,6 +62,7 @@ namespace Tgstation.Server.Host.Setup services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); + services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); ConfigureHostedService(services); diff --git a/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs b/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs index 94c1c00664..b4933d7b6c 100644 --- a/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs +++ b/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs @@ -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 /// public string VersionString { get; } + /// + public ProductInfoHeaderValue ProductInfoHeaderValue => new ProductInfoHeaderValue( + VersionPrefix, + Version.ToString()); + /// /// Initializes a new instance of the . /// diff --git a/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs b/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs index 30c97a618d..162b627888 100644 --- a/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs +++ b/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs @@ -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. /// Version Version { get; } + + /// + /// The for the assembly. + /// + ProductInfoHeaderValue ProductInfoHeaderValue { get; } } } diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index f4872a4579..f8d5296d9d 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -1,4 +1,4 @@ - + @@ -66,20 +66,20 @@ - - - + + + all runtime; build; native; contentfiles; analyzers - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -87,7 +87,7 @@ - + @@ -97,13 +97,13 @@ all runtime; build; native; contentfiles; analyzers - - + + - - - - + + + + diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json index 63f9edf1ff..00506b5762 100644 --- a/src/Tgstation.Server.Host/appsettings.json +++ b/src/Tgstation.Server.Host/appsettings.json @@ -54,6 +54,11 @@ "TokenExpiryMinutes": 15, "TokenClockSkewMinutes": 1, "TokenSigningKeyByteAmount": 256, - "CustomTokenSigningKeyBase64": null + "CustomTokenSigningKeyBase64": null, + "OAuth": { + "GitHub": null, + "Discord": null, + "TGForums": null + } } } diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 0f78162c50..8a310fb869 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -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(() => new ApiHeaders(null, null)); Assert.ThrowsException(() => new ApiHeaders(productHeaderValue, null)); var headers = new ApiHeaders(productHeaderValue, String.Empty); + headers = new ApiHeaders(productHeaderValue, String.Empty, OAuthProvider.GitHub); } [TestMethod] diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs b/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs index 192bc07957..7a8c565f6b 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs @@ -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(); - 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(); - 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); diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index 5fcb6e5a42..3953594b8d 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -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(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( diff --git a/tests/Tgstation.Server.Tests/RootTest.cs b/tests/Tgstation.Server.Tests/RootTest.cs index b94e2c3dc7..be2b87cb95 100644 --- a/tests/Tgstation.Server.Tests/RootTest.cs +++ b/tests/Tgstation.Server.Tests/RootTest.cs @@ -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(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + var message = JsonConvert.DeserializeObject(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(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(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(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(() => badClient.Version(cancellationToken)).ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => 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)); } } diff --git a/tests/Tgstation.Server.Tests/TestingServer.cs b/tests/Tgstation.Server.Tests/TestingServer.cs index 8962ce2d58..7eee0642a1 100644 --- a/tests/Tgstation.Server.Tests/TestingServer.cs +++ b/tests/Tgstation.Server.Tests/TestingServer.cs @@ -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)); diff --git a/tests/Tgstation.Server.Tests/UsersTest.cs b/tests/Tgstation.Server.Tests/UsersTest.cs index 7241361cf9..fade7832a0 100644 --- a/tests/Tgstation.Server.Tests/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/UsersTest.cs @@ -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);