using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using AyaNova.Models; using AyaNova.Util; using AyaNova.Api.ControllerHelpers; using System.Linq; using System; using System.Threading.Tasks; using App.Metrics; using AyaNova.Biz; //required to inject configuration in constructor using Microsoft.Extensions.Configuration; namespace AyaNova.Api.Controllers { /// /// Authentication controller /// [ApiController] [ApiVersion("8.0")] [Route("api/v{version:apiVersion}/[controller]")] [Produces("application/json")] public class AuthController : ControllerBase { private readonly AyContext ct; private readonly ILogger log; private readonly IConfiguration _configuration; private readonly ApiServerState serverState; private readonly IMetrics metrics; private const int JWT_LIFETIME_DAYS = 7; /// /// ctor /// /// /// /// /// /// public AuthController(AyContext context, ILogger logger, IConfiguration configuration, ApiServerState apiServerState, IMetrics Metrics)//these two are injected, see startup.cs { ct = context; log = logger; _configuration = configuration; serverState = apiServerState; metrics = Metrics; } //AUTHENTICATE CREDS //RETURN JWT /// /// Post credentials to receive a JSON web token /// /// /// This route is used to authenticate to the AyaNova API. /// Once you have a token you need to include it in all requests that require authentication like this: /// Authorization: Bearer [TOKEN] /// Note the space between Bearer and the token. Also, do not include the square brackets /// /// /// [HttpPost] public async Task PostCreds([FromBody] AuthController.CredentialsParam creds) //if was a json body then //public JsonResult PostCreds([FromBody] string login, [FromBody] string password) { //a bit different as ops users can still login if the state is opsonly //so the only real barrier here would be a completely closed api if (serverState.IsClosed) return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); int nFailedAuthDelay = 3000;//should be just long enough to make brute force a hassle but short enough to not annoy people who just mistyped their creds to login #if (DEBUG) nFailedAuthDelay = 1; #region TESTING //TEST JWT's with various flaws for testing purposes: if (creds.Login == "INTEGRATION_TEST") { //build the key (JWT set in startup.cs) byte[] secretKey = System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET); //create a new datetime offset of now in utc time var iat = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero);//timespan zero means zero time off utc / specifying this is a UTC datetime var exp = new DateTimeOffset(DateTime.Now.AddDays(30).ToUniversalTime(), TimeSpan.Zero); string Issuer = "ayanova.com"; var Algorithm = Jose.JwsAlgorithm.HS256; //Pre JWT creation test payloads switch (creds.Password) { case "EXPIRED": exp = new DateTimeOffset(DateTime.Now.AddDays(-30).ToUniversalTime(), TimeSpan.Zero); break; case "WRONG_ISSUER": Issuer = "Bogus"; break; case "NO_ALGORITHM": Algorithm = Jose.JwsAlgorithm.none; break; case "WRONG_SECRET": secretKey = System.Text.Encoding.ASCII.GetBytes("xxxxxxThisIsObviouslyWrongxxxxxx"); break; } var payload = new Dictionary() { { "iat", iat.ToUnixTimeSeconds().ToString() }, { "exp", exp.ToUnixTimeSeconds().ToString() },//in payload exp must be in unix epoch time per standard { "iss", Issuer }, { "id", "1" }, { "name","Manager Account - TESTING"}, { "ayanova/roles", "0" } }; string TestToken = Jose.JWT.Encode(payload, secretKey, Algorithm); //Post JWT creation test payloads switch (creds.Password) { case "TRUNCATED_SIGNATURE": TestToken = TestToken.Substring(0, TestToken.Length - 3); break; case "TRANSPOSE_SIGNATURE": //Transpose two characters in the signature int len = TestToken.Length; var Transposed = TestToken.Substring(0, len - 5) + TestToken[len - 4] + TestToken[len - 5] + TestToken.Substring(len - 3, 3); TestToken = Transposed; break; } return Ok(ApiOkResponse.Response(new { token = TestToken }, true)); } #endregion testing #endif if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password)) { metrics.Measure.Meter.Mark(MetricsRegistry.FailedLoginMeter); //Make a failed pw wait await Task.Delay(nFailedAuthDelay); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } //Multiple users are allowed the same password and login //Salt will differentiate them so get all users that match login, then try to match pw var users = await ct.User.Where(m => m.Login == creds.Login && m.Active == true).ToListAsync(); foreach (User u in users) { string hashed = Hasher.hash(u.Salt, creds.Password); if (hashed == u.Password) { //Restrict auth due to server state? //If we're here the server state is not closed, but it might be ops only //If the server is ops only then this user needs to be ops or else they are not allowed in if (serverState.IsOpsOnly && !u.Roles.HasFlag(Biz.AuthorizationRoles.OpsAdminFull) && !u.Roles.HasFlag(Biz.AuthorizationRoles.OpsAdminLimited)) { return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); } //build the key (JWT set in startup.cs) byte[] secretKey = System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET); //create a new datetime offset of now in utc time var iat = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero);//timespan zero means zero time off utc / specifying this is a UTC datetime var exp = new DateTimeOffset(DateTime.Now.AddDays(JWT_LIFETIME_DAYS).ToUniversalTime(), TimeSpan.Zero); //=============== download token =================== //Generate a download token and store it with the user account //string DownloadToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); string DownloadToken = Hasher.GenerateSalt(); DownloadToken = DownloadToken.Replace("=", ""); DownloadToken = DownloadToken.Replace("+", ""); u.DlKey = DownloadToken; u.DlKeyExpire = exp.DateTime; await ct.SaveChangesAsync(); //======================================================= var payload = new Dictionary() { { "iat", iat.ToUnixTimeSeconds().ToString() }, { "exp", exp.ToUnixTimeSeconds().ToString() },//in payload exp must be in unix epoch time per standard { "iss", "ayanova.com" }, { "id", u.Id.ToString() }, { "name", u.Name}, { "usertype", u.UserType}, { "ayanova/roles", ((int)u.Roles).ToString()}, { "dlt", DownloadToken } }; //NOTE: probably don't need Jose.JWT as am using Microsoft jwt stuff to validate routes so it should also be able to //issue tokens as well, but it looked cmplex and this works so unless need to remove in future keeping it. string token = Jose.JWT.Encode(payload, secretKey, Jose.JwsAlgorithm.HS256); log.LogDebug($"User number \"{u.Id}\" logged in from \"{Util.StringUtil.MaskIPAddress(HttpContext.Connection.RemoteIpAddress.ToString())}\" ok"); metrics.Measure.Meter.Mark(MetricsRegistry.SuccessfulLoginMeter); //TODO: This needs to return the authorization roles of the user in the payload and it should all be in the token //and remove the issued, expires id etc so that all that is returned is an encoded token with that info in it return Ok(ApiOkResponse.Response(new { // ok = 1, // issued = iat, // expires = exp, token = token //, //id = u.Id }, true)); } } //No users matched, it's a failed login //Make a failed pw wait metrics.Measure.Meter.Mark(MetricsRegistry.FailedLoginMeter); await Task.Delay(nFailedAuthDelay); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } /// /// Change Password /// /// /// [HttpPost("ChangePassword")] public async Task ChangePassword([FromBody] AuthController.ChangePasswordParam changecreds) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!ModelState.IsValid) { return BadRequest(new ApiErrorResponse(ModelState)); } int nFailedAuthDelay = 3000;//should be just long enough to make brute force a hassle but short enough to not annoy people who just mistyped their creds to login if (string.IsNullOrWhiteSpace(changecreds.OldPassword) || string.IsNullOrWhiteSpace(changecreds.LoginName)) { metrics.Measure.Meter.Mark(MetricsRegistry.FailedLoginMeter); //Make a failed pw wait await Task.Delay(nFailedAuthDelay); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } if (string.IsNullOrWhiteSpace(changecreds.NewPassword)) { return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "NewPassword")); } if (changecreds.NewPassword != changecreds.ConfirmPassword) { return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "ConfirmPassword", "NewPassword does not match ConfirmPassword")); } //Multiple users are allowed the same password and login //Salt will differentiate them so get all users that match login, then try to match pw var users = await ct.User.AsNoTracking().Where(m => m.Login == changecreds.LoginName).ToListAsync(); foreach (User u in users) { string hashed = Hasher.hash(u.Salt, changecreds.OldPassword); if (hashed == u.Password) { //If the user is inactive they may not login if (!u.Active) { //respond like bad creds so as not to leak information return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } //fetch and update user //Instantiate the business object handler UserBiz biz = UserBiz.GetBiz(ct, HttpContext); await biz.ChangePasswordAsync(u.Id, changecreds.NewPassword); return NoContent(); } } //No users matched, it's a failed login //Make a failed pw wait await Task.Delay(nFailedAuthDelay); return BadRequest(new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); //return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } //------------------------------------------------------ public class CredentialsParam { [System.ComponentModel.DataAnnotations.Required] public string Login { get; set; } [System.ComponentModel.DataAnnotations.Required] public string Password { get; set; } } public class ChangePasswordParam { [System.ComponentModel.DataAnnotations.Required] public string LoginName { get; set; } [System.ComponentModel.DataAnnotations.Required] public string OldPassword { get; set; } [System.ComponentModel.DataAnnotations.Required] public string NewPassword { get; set; } [System.ComponentModel.DataAnnotations.Required] public string ConfirmPassword { get; set; } } }//eoc }//eons