// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; using System.Security.Claims; using System.Text; using System.Threading.Tasks; using EpicGames.Horde.Accounts; using EpicGames.Horde.Acls; using EpicGames.Horde.Server; using EpicGames.Horde.Users; using HordeServer.Accounts; using HordeServer.Acls; using HordeServer.Authentication; using HordeServer.Users; using HordeServer.Utilities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; #pragma warning disable CA1054 // URI-like parameters should not be strings namespace HordeServer.Server { /// /// Model for Horde account login view /// public class HordeAccountLoginViewModel { /// /// Where to post the form /// public string? FormPostUrl { get; set; } /// /// Optional error message to display /// public string? ErrorMessage { get; set; } } /// /// Controller managing account status /// [ApiController] [Route("[controller]")] public class AccountController : Controller { /// /// Style sheet for HTML responses /// const string StyleSheet = "body { font-family: 'Segoe UI', 'Roboto', arial, sans-serif; } " + "p { margin:20px; font-size:13px; } " + "h1 { margin: 20px; font-size: 32px; font-weight: 200; } " + "h2 { margin: 20px; font-size: 24px; font-weight: 200; } " + "table { margin:10px 20px; } " + "th { margin: 5px; font-size: 13px; vertical-align: top; padding: 3px; text-align: left; }" + "td { margin:5px; font-size:13px; vertical-align: top; padding: 3px; font-family: Consolas, Courier New, monospace; }"; readonly IUserCollection _users; readonly IAccountCollection _hordeAccounts; readonly string _authenticationScheme; readonly IOptionsSnapshot _globalConfig; readonly IOptionsMonitor _settings; /// /// Constructor /// public AccountController(IUserCollection users, IAccountCollection hordeAccounts, IOptionsMonitor serverSettings, IOptionsSnapshot globalConfig, IOptionsMonitor settings) { _users = users; _hordeAccounts = hordeAccounts; _authenticationScheme = GetAuthScheme(serverSettings.CurrentValue.AuthMethod); _globalConfig = globalConfig; _settings = settings; } /// /// Get auth scheme name for a given auth method /// /// Authentication method /// Name of authentication scheme public static string GetAuthScheme(AuthMethod method) { return method switch { AuthMethod.Anonymous => AnonymousAuthHandler.AuthenticationScheme, AuthMethod.Okta => OktaAuthHandler.AuthenticationScheme, AuthMethod.OpenIdConnect => OpenIdConnectDefaults.AuthenticationScheme, AuthMethod.Horde => CookieAuthenticationDefaults.AuthenticationScheme, _ => throw new ArgumentOutOfRangeException(nameof(method), method, null) }; } /// /// Gets the current login status /// /// The current login state [HttpGet] [Route("/account")] public ActionResult State() { StringBuilder content = new StringBuilder(); content.Append($"

Horde Server

"); if (User.Identity?.IsAuthenticated ?? false) { content.Append(CultureInfo.InvariantCulture, $"

User {User.Identity?.Name} is logged in. Log out

"); if (_globalConfig.Value.Authorize(AdminAclAction.AdminWrite, User)) { content.Append("

"); content.Append("Get bearer token
"); content.Append("Get agent registration token
"); content.Append("Get agent software upload token
"); content.Append("Get agent software download token
"); content.Append("Get configuration token
"); content.Append("Get chained job token
"); content.Append("

"); } content.AppendLine(CultureInfo.InvariantCulture, $"

Claims for {User.Identity?.Name}:

"); content.AppendLine(""); content.AppendLine(""); foreach (System.Security.Claims.Claim claim in User.Claims) { content.AppendLine(CultureInfo.InvariantCulture, $""); } content.AppendLine("
TypeValue
{claim.Type}{claim.Value}
"); content.AppendLine("

ACL Permissions

"); content.AppendLine("

Only scopes with at least one authorized action are shown.

"); content.AppendLine(""); content.AppendLine(""); List aclPermissions = UserController.GetUserAclPermissions(_globalConfig.Value, User); Dictionary> scopeToPermissions = aclPermissions .GroupBy(permission => permission.Scope) .ToDictionary(group => group.Key, group => group.ToList()); foreach ((string scope, List permissions) in scopeToPermissions) { if (permissions.Count > 0) { for (int i = 0; i < permissions.Count; i++) { UserAclPermission uap = permissions[i]; if (i == 0) { content.Append(""); content.AppendLine($""); } else { string color = uap.IsAuthorized ? "#c8ffc8" : "#ffc8c8"; string authorized = uap.IsAuthorized ? "Yes" : "No"; content.AppendLine($""); } content.Append(""); } } content.AppendLine(""); content.AppendLine(""); } if (scopeToPermissions.Count == 0) { content.AppendLine(""); } content.AppendLine("
ScopeActionAuthorized
{scope}{uap.Action}{authorized}
No scopes
"); content.AppendLine("

Built from Perforce

"); } else { content.AppendLine("

Login

"); } content.AppendLine(""); return new ContentResult { ContentType = "text/html", StatusCode = (int)HttpStatusCode.OK, Content = content.ToString() }; } /// /// Logged out page /// /// HTML [HttpGet] [Route("/account/logged-out")] public ViewResult LoggedOut() { return View("~/Server/HordeAccountLoggedOut.cshtml"); } /// /// Show login form for username/password login /// /// HTML for a login form [HttpGet] [Route("/account/login/horde")] public IActionResult UserPassLoginForm(string? returnUrl = null) { if (User.Identity is { IsAuthenticated: true }) { // Redirect if already logged in return Redirect(returnUrl ?? "/"); } return View("~/Server/HordeAccountLogin.cshtml", new HordeAccountLoginViewModel { FormPostUrl = Url.Action("UserPassLogin", "Account", returnUrl != null ? new { returnUrl } : null) }); } /// /// Perform a login with username/password credentials /// /// An HTTP redirect if successful [HttpPost] [Route("/account/login/horde")] public async Task UserPassLoginAsync(string? returnUrl = null) { if (_globalConfig.Value.ServerSettings.AuthMethod != AuthMethod.Horde) { return Forbid("Horde built-in authentication is disabled"); } const string ErrorMsg = "Invalid username or password"; string? username = Request.Form["username"]; string password = (string?)Request.Form["password"] ?? String.Empty; bool success = await SignInAsync(username, password); if (!success) { return LoginFormError(ErrorMsg, returnUrl); } return Redirect(returnUrl ?? "/"); } /// /// Dashboard login /// /// /// [HttpPost] [Route("/account/login/dashboard")] public async Task UserDashboardLoginAsync([FromBody] DashboardLoginRequest request) { if (_globalConfig.Value.ServerSettings.AuthMethod != AuthMethod.Horde) { return Forbid(); } bool success = await SignInAsync(request.Username, request.Password); if (!success) { return Forbid(); } return Redirect(request.ReturnUrl ?? "/"); } private ViewResult LoginFormError(string message, string? returnUrl = null, HttpStatusCode statusCode = HttpStatusCode.BadRequest) { Response.StatusCode = (int)statusCode; return View("~/Server/HordeAccountLogin.cshtml", new HordeAccountLoginViewModel { FormPostUrl = Url.Action("UserPassLogin", "Account", returnUrl != null ? new { returnUrl } : null), ErrorMessage = message }); } /// /// Sign into a Horde auth account /// /// /// /// async Task SignInAsync(string? login, string? password) { if (String.IsNullOrEmpty(login)) { return false; } IAccount? account = await _hordeAccounts.FindByLoginAsync(login); if (account is not { Enabled: true }) { return false; } if (!String.IsNullOrEmpty(account.PasswordHash)) { byte[] correctHash = PasswordHasher.HashFromString(account.PasswordHash); byte[] salt = PasswordHasher.SaltFromString(account.PasswordSalt); if (!PasswordHasher.ValidatePassword(password ?? "", salt, correctHash)) { return false; } } IUser user = await _users.FindOrAddUserByLoginAsync(account.Login, account.Name, account.Email); List claims = new() { new Claim(HordeClaimTypes.Version, HordeClaimTypes.CurrentVersion), new Claim(HordeClaimTypes.AccountId, account.Id.ToString()), new Claim(ClaimTypes.Name, account.Name), new Claim(HordeClaimTypes.User, account.Login), new Claim(HordeClaimTypes.UserId, user.Id.ToString()), }; if (!String.IsNullOrEmpty(account.Email)) { claims.Add(new Claim(ClaimTypes.Email, account.Email)); } if (!String.IsNullOrEmpty(_settings.CurrentValue.AdminClaimType) && !String.IsNullOrEmpty(_settings.CurrentValue.AdminClaimValue) && account.HasClaim(_settings.CurrentValue.AdminClaimType, _settings.CurrentValue.AdminClaimValue)) { claims.Add(HordeClaims.AdminClaim.ToClaim()); } foreach (IUserClaim claim in account.Claims) { claims.Add(new Claim(claim.Type, claim.Value)); } ClaimsIdentity claimsIdentity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme); AuthenticationProperties authProperties = new() { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7) }; await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(claimsIdentity), authProperties); return true; } /// /// Login to the server /// /// Http result [HttpGet] [Route("/account/login")] public IActionResult Login() { return new ChallengeResult(_authenticationScheme, new AuthenticationProperties { RedirectUri = "/account" }); } /// /// Logout of the current account /// /// Http result [HttpGet] [Route("/account/logout")] public async Task LogoutAsync() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); try { await HttpContext.SignOutAsync(_authenticationScheme); } catch { } string content = $"

User has been logged out. Returning to login page.

"; return new ContentResult { ContentType = "text/html", StatusCode = (int)HttpStatusCode.OK, Content = content }; } /// /// Gets information about the current account /// [HttpGet] [Route("/account/entitlements")] [ProducesResponseType(typeof(GetAccountEntitlementsResponse), 200)] public ActionResult GetCurrentAccountEntitlements([FromQuery] PropertyFilter? filter = null) { GetAccountEntitlementsResponse response = CreateGetAccountEntitlementsResponse(_globalConfig.Value.Acl, claim => User.HasClaim(claim.Type, claim.Value)); return PropertyFilter.Apply(response, filter); } internal static GetAccountEntitlementsResponse CreateGetAccountEntitlementsResponse(AclConfig rootAclConfig, Predicate predicate) { Dictionary> scopeToActions = rootAclConfig.FindEntitlements(predicate); List scopes = new List(); foreach ((AclScopeName scopeName, HashSet actions) in scopeToActions) { scopes.Add(new GetAccountScopeEntitlementsResponse(scopeName.Text, actions.OrderBy(x => x.Name).ToList())); } return new GetAccountEntitlementsResponse(predicate(HordeClaims.AdminClaim), scopes); } /// /// Tests whether a user has a particular entitlement /// [HttpGet] [Route("/account/access")] public ActionResult GetAccess([FromQuery] AclScopeName scope, [FromQuery] AclAction action, [FromQuery] PropertyFilter? filter = null) { AclConfig? scopeConfig; if (!_globalConfig.Value.TryGetAclScope(scope, out scopeConfig)) { return NotFound(); } bool access = scopeConfig.Authorize(action, User); List scopes = new List(); for (AclConfig? testScopeConfig = scopeConfig; testScopeConfig != null; testScopeConfig = testScopeConfig.Parent) { scopes.Add(new { Name = testScopeConfig.ScopeName, Access = testScopeConfig.Authorize(action, User) }); } return PropertyFilter.Apply(new { access, scopes }, filter); } } }