diff --git a/server/AyaNova/Controllers/AuthController.cs b/server/AyaNova/Controllers/AuthController.cs index a5b7fadb..7df8ab0c 100644 --- a/server/AyaNova/Controllers/AuthController.cs +++ b/server/AyaNova/Controllers/AuthController.cs @@ -169,104 +169,110 @@ namespace AyaNova.Api.Controllers if (hashed == u.Password) { //Valid password, user is effectively authorized at this point + return await ReturnUserCredsOnSuccessfulAuthentication(u); - //check if server available to SuperUser account only (closed or migrate mode) - //if it is it means we got here either because there is no license - //and only *the* SuperUser account can login now or we're in migrate mode - if (serverState.IsClosed || serverState.IsMigrateMode) - { - //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 + // //check if server available to SuperUser account only (closed or migrate mode) + // //if it is it means we got here either because there is no license + // //and only *the* SuperUser account can login now or we're in migrate mode + // if (serverState.IsClosed || serverState.IsMigrateMode) + // { + // //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)); - } + // //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)); + // } - //TWO FACTOR ENABLED?? - //if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt - if (u.TwoFactorEnabled) - { - List TranslationKeysToFetch = new List { "AuthTwoFactor", "AuthEnterPin", "AuthVerifyCode", "Cancel" }; - var LT = await TranslationBiz.GetSubsetStaticAsync(TranslationKeysToFetch, u.UserOptions.TranslationId); + // //TWO FACTOR ENABLED?? + // //if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt + // if (u.TwoFactorEnabled) + // { + // //Generate a temporary token to identify and verify this is the same user + // u.TempToken = Hasher.GenerateSalt().Replace("=", "").Replace("+", ""); + // await ct.SaveChangesAsync(); - return Ok(ApiOkResponse.Response(new - { - AuthTwoFactor = LT["AuthTwoFactor"], - AuthEnterPin = LT["AuthEnterPin"], - AuthVerifyCode = LT["AuthVerifyCode"], - Cancel = LT["Cancel"], - tfa = true - })); - } + // List TranslationKeysToFetch = new List { "AuthTwoFactor", "AuthEnterPin", "AuthVerifyCode", "Cancel" }; + // var LT = await TranslationBiz.GetSubsetStaticAsync(TranslationKeysToFetch, u.UserOptions.TranslationId); + + // return Ok(ApiOkResponse.Response(new + // { + // AuthTwoFactor = LT["AuthTwoFactor"], + // AuthEnterPin = LT["AuthEnterPin"], + // AuthVerifyCode = LT["AuthVerifyCode"], + // Cancel = LT["Cancel"], + // tfa = true, + // tt = u.TempToken + // })); + // } - //build the key (JWT set in startup.cs) - byte[] secretKey = System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET); + // //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); + // //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; + // //=============== 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() } - }; + // 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); + // //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; + // //save auth token to ensure single sign on only + // u.CurrentAuthToken = token; - u.LastLogin = DateTime.UtcNow; + // u.LastLogin = DateTime.UtcNow; - await ct.SaveChangesAsync(); + // 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"); + // //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"); + // 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, - tfa = u.TwoFactorEnabled - })); + // return Ok(ApiOkResponse.Response(new + // { + // token = token, + // name = u.Name, + // usertype = u.UserType, + // roles = ((int)u.Roles).ToString(), + // dlt = DownloadToken, + // tfa = u.TwoFactorEnabled + // })); } } @@ -277,6 +283,169 @@ namespace AyaNova.Api.Controllers } + + + /// + /// Verify tfa code + /// + /// + /// This route is used to authenticate to the AyaNova API via tfa code. + /// 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("tfa-authenticate")] + [AllowAnonymous] + public async Task TfaAuthenticate([FromBody] TFAPinParam pin) + { + //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 (string.IsNullOrWhiteSpace(pin.Pin)) + { + //Make a failed pw wait + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); + } + + //Match to temp token that would have been set by initial credentialed login for 2fa User + var user = await ct.User.Where(z => z.TempToken == pin.TempToken && z.Active == true && z.TwoFactorEnabled == true).FirstOrDefaultAsync(); + + + if (user != null) + { + //Valid temp token, now check the pin code is right + if (string.IsNullOrWhiteSpace(user.TotpSecret)) + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "generalerror", "2fa not activated")); + } + + //ok, something to validate, let's validate it + var tfa = new TwoFactorAuth("AyaNova"); + if (!tfa.VerifyCode(user.TotpSecret, pin.Pin.Replace(" ", "").Trim())) + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); + } + + //User is valid and authenticated + //clear temp token + user.TempToken = string.Empty; + await ct.SaveChangesAsync(); + + return await ReturnUserCredsOnSuccessfulAuthentication(user); + } + + //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)); + } + + private async Task ReturnUserCredsOnSuccessfulAuthentication(User u) + { + + + //check if server available to SuperUser account only (closed or migrate mode) + //if it is it means we got here either because there is no license + //and only *the* SuperUser account can login now or we're in migrate mode + if (serverState.IsClosed || serverState.IsMigrateMode) + { + //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, + tfa = u.TwoFactorEnabled + })); + + //------------------------ /STANDARD BLOCK ------------------------- + } + + + + + + + + + /// /// Change Password /// @@ -294,12 +463,9 @@ namespace AyaNova.Api.Controllers 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); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } @@ -323,7 +489,7 @@ namespace AyaNova.Api.Controllers if (!u.Active) { //respond like bad creds so as not to leak information - await Task.Delay(nFailedAuthDelay); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } @@ -331,7 +497,7 @@ namespace AyaNova.Api.Controllers //otherwise it's feasible someone could change someone else's password through their own change password form with a mis-type or intentional hack if (u.Id != UserIdFromContext.Id(HttpContext.Items)) { - await Task.Delay(nFailedAuthDelay); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } @@ -347,11 +513,9 @@ namespace AyaNova.Api.Controllers //No users matched, it's a failed login //Make a failed pw wait - await Task.Delay(nFailedAuthDelay); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return BadRequest(new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); - - //return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); } @@ -371,18 +535,17 @@ namespace AyaNova.Api.Controllers { return BadRequest(new ApiErrorResponse(ModelState)); } - int nFailDelay = 3000; + if (string.IsNullOrWhiteSpace(resetcreds.PasswordResetCode)) { - //Make a fail wait - await Task.Delay(nFailDelay); + //Make a fail wait + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); 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); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_REQUIRED, "Password", "Password is required")); } @@ -390,15 +553,13 @@ namespace AyaNova.Api.Controllers var user = await ct.User.AsNoTracking().Where(z => z.PasswordResetCode == resetcreds.PasswordResetCode).FirstOrDefaultAsync(); if (user == null) { - //Make a fail wait - await Task.Delay(nFailDelay); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); 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); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, "PasswordResetCode", "Reset code not valid")); } @@ -406,8 +567,7 @@ namespace AyaNova.Api.Controllers 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); + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); 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 @@ -437,7 +597,10 @@ namespace AyaNova.Api.Controllers return BadRequest(new ApiErrorResponse(ModelState)); uint res = await biz.SendPasswordResetCode(id); if (res == 0) + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return BadRequest(new ApiErrorResponse(biz.Errors)); + } else return Ok(ApiOkResponse.Response(new { @@ -502,7 +665,7 @@ namespace AyaNova.Api.Controllers /// From route path /// OK on success [HttpPost("totp-validate")] - public async Task ValidateTOTP([FromBody] OTPPinParam pin, ApiVersion apiVersion) + public async Task ValidateTOTP([FromBody] TFAPinParam pin, ApiVersion apiVersion) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); @@ -515,13 +678,22 @@ namespace AyaNova.Api.Controllers var u = await ct.User.FirstOrDefaultAsync(z => z.Id == UserId); if (u == null)//should never happen but ? + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return StatusCode(403, new ApiNotAuthorizedResponse()); + } if (u.TwoFactorEnabled) + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "generalerror", "2fa already enabled")); + } if (string.IsNullOrWhiteSpace(u.TotpSecret)) + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "generalerror", "2fa activation code not requested yet (missed a step?)")); + } //ok, something to validate, let's validate it var tfa = new TwoFactorAuth("AyaNova"); @@ -532,6 +704,10 @@ namespace AyaNova.Api.Controllers u.TwoFactorEnabled = true; await ct.SaveChangesAsync(); } + else + { + await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); + } return Ok(ApiOkResponse.Response(new { @@ -601,10 +777,11 @@ namespace AyaNova.Api.Controllers public string Password { get; set; } } - public class OTPPinParam + public class TFAPinParam { [System.ComponentModel.DataAnnotations.Required] public string Pin { get; set; } + public string TempToken { get; set; } } diff --git a/server/AyaNova/models/User.cs b/server/AyaNova/models/User.cs index 0f63be1f..eeb9ece3 100644 --- a/server/AyaNova/models/User.cs +++ b/server/AyaNova/models/User.cs @@ -98,6 +98,8 @@ namespace AyaNova.Models public DateTime? PasswordResetCodeExpire { get; set; }//--- [JsonIgnore] public string TotpSecret { get; set; }//--- + [JsonIgnore] + public string TempToken { get; set; }//--- Used for TFA verification step 2 //========================== //relations diff --git a/server/AyaNova/util/AySchema.cs b/server/AyaNova/util/AySchema.cs index 63a37401..c3597366 100644 --- a/server/AyaNova/util/AySchema.cs +++ b/server/AyaNova/util/AySchema.cs @@ -441,7 +441,7 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); //Add user table await ExecQueryAsync("CREATE TABLE auser (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, active BOOL NOT NULL, name TEXT NOT NULL UNIQUE, " + "lastlogin TIMESTAMP, login TEXT NOT NULL UNIQUE, password TEXT NOT NULL, salt TEXT NOT NULL, roles INTEGER NOT NULL, currentauthtoken TEXT, " - + "dlkey TEXT, dlkeyexpire TIMESTAMP, totpsecret TEXT, twofactorenabled BOOL, passwordresetcode TEXT, passwordresetcodeexpire TIMESTAMP, usertype INTEGER NOT NULL, " + + "dlkey TEXT, dlkeyexpire TIMESTAMP, totpsecret TEXT, temptoken TEXT, twofactorenabled BOOL, passwordresetcode TEXT, passwordresetcodeexpire TIMESTAMP, usertype INTEGER NOT NULL, " + "employeenumber TEXT, notes TEXT, customerid BIGINT, " + "headofficeid BIGINT, vendorid BIGINT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY)");