using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; 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 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}/auth")] [Produces("application/json")] [Authorize] public class AuthController : ControllerBase { private readonly AyContext ct; private readonly ILogger log; private readonly IConfiguration _configuration; private readonly ApiServerState serverState; private const int JWT_LIFETIME_DAYS = 7; /// /// ctor /// /// /// /// /// public AuthController(AyContext context, ILogger logger, IConfiguration configuration, ApiServerState apiServerState) { ct = context; log = logger; _configuration = configuration; serverState = apiServerState; } //AUTHENTICATE CREDS //RETURN JWT /// /// Create 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] [AllowAnonymous] 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 && AyaNova.Core.License.ActiveKey.KeyDoesNotNeedAttention) { return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); } #if (DEBUG) #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" } }; 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, name = "SuperUser Account - TESTING", roles = "0" })); } #endregion testing #endif if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password)) { //Make a failed pw wait await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); 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(z => z.Login == creds.Login && z.Active == true).ToListAsync(); foreach (User u in users) { string hashed = Hasher.hash(u.Salt, creds.Password); if (hashed == u.Password) { //Valid password, user is effectively authorized at this point //check if server closed //if it is it means we got here only because there is no license //and only *the* SuperUser account can login now if (serverState.IsClosed) { //if not SuperUser account then boot closed //SuperUser account is always ID 1 if (u.Id != 1) { return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); } } //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; //======================================================= 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() } }; //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); //save auth token to ensure single sign on only u.CurrentAuthToken = token; u.LastLogin = DateTime.UtcNow; await ct.SaveChangesAsync(); //KEEP this, masked version of IP address //Not sure if this is necessary or not but if it turns out to be then make it a boot option // log.LogInformation($"User number \"{u.Id}\" logged in from \"{Util.StringUtil.MaskIPAddress(HttpContext.Connection.RemoteIpAddress.ToString())}\" ok"); log.LogInformation($"User \"{u.Name}\" logged in from \"{HttpContext.Connection.RemoteIpAddress.ToString()}\" ok"); return Ok(ApiOkResponse.Response(new { token = token, name = u.Name, usertype = u.UserType, roles = ((int)u.Roles).ToString(), dlt = DownloadToken })); } } //No users matched, it's a failed login //Make a failed pw wait await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } /// /// Change Password /// /// /// [HttpPost("change-password")] public async Task ChangePassword([FromBody] AuthController.ChangePasswordParam changecreds) { //Note: need to be authenticated to use this, only called from own user's UI //it still asks for old creds in case someone attempts to do this on another user's logged in session 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)) { //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(z => z.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)); } /// /// Change Password via reset token /// /// /// [HttpPost("reset-password")] [AllowAnonymous] public async Task ResetPassword([FromBody] AuthController.ResetPasswordParam resetcreds) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!ModelState.IsValid) { return BadRequest(new ApiErrorResponse(ModelState)); } int nFailDelay = 3000; if (string.IsNullOrWhiteSpace(resetcreds.PasswordResetCode)) { //Make a fail wait await Task.Delay(nFailDelay); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "PasswordResetCode", "Reset code is required")); } if (string.IsNullOrWhiteSpace(resetcreds.Password)) { //Make a fail wait await Task.Delay(nFailDelay); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "Password", "Password is required")); } //look for user with this reset code var user = await ct.User.AsNoTracking().Where(z => z.PasswordResetCode == resetcreds.PasswordResetCode).FirstOrDefaultAsync(); if (user == null) { //Make a fail wait await Task.Delay(nFailDelay); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "PasswordResetCode", "Reset code not valid")); } if (string.IsNullOrWhiteSpace(user.PasswordResetCode) || user.PasswordResetCodeExpire == null) { //Make a fail wait await Task.Delay(nFailDelay); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "PasswordResetCode", "Reset code not valid")); } //vet the expiry var utcNow = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero); if (user.PasswordResetCodeExpire < utcNow.DateTime) {//if reset code expired before now //Make a fail wait await Task.Delay(nFailDelay); return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_AUTHORIZED, "PasswordResetCodeExpire", "Reset code has expired")); } //Ok, were in, it's all good, accept the new password and update the user record UserBiz biz = UserBiz.GetBiz(ct, HttpContext); await biz.ChangePasswordAsync(user.Id, resetcreds.Password); return NoContent(); } /// /// Generate time limited password reset code for User /// and email link to them so they can set their password /// /// /// User id /// From route path /// New concurrency code [HttpPost("request-reset-password/{id}")] public async Task SendPasswordResetCode([FromRoute] long id, ApiVersion apiVersion) { //Note: this is not allowed for an anonymous users because it's only intended for now to work for staff user's who will send the request on behalf of the User if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); UserBiz biz = UserBiz.GetBiz(ct, HttpContext); if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType)) return StatusCode(403, new ApiNotAuthorizedResponse()); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); uint res = await biz.SendPasswordResetCode(id); if (res == 0) return BadRequest(new ApiErrorResponse(biz.Errors)); else return Ok(ApiOkResponse.Response(new { concurrency = res })); } //------------------------------------------------------ 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; } } public class ResetPasswordParam { [System.ComponentModel.DataAnnotations.Required] public string PasswordResetCode { get; set; } [System.ComponentModel.DataAnnotations.Required] public string Password { get; set; } } }//eoc }//eons