741 lines
32 KiB
C#
741 lines
32 KiB
C#
using System.Collections.Generic;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using TwoFactorAuthNet;
|
|
using QRCoder;
|
|
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
|
|
{
|
|
/// <summary>
|
|
/// Authentication controller
|
|
/// </summary>
|
|
[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<AuthController> log;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ApiServerState serverState;
|
|
private const int JWT_LIFETIME_DAYS = 7;
|
|
|
|
/// <summary>
|
|
/// ctor
|
|
/// </summary>
|
|
/// <param name="context"></param>
|
|
/// <param name="logger"></param>
|
|
/// <param name="configuration"></param>
|
|
/// <param name="apiServerState"></param>
|
|
public AuthController(AyContext context, ILogger<AuthController> logger, IConfiguration configuration, ApiServerState apiServerState)
|
|
{
|
|
ct = context;
|
|
log = logger;
|
|
_configuration = configuration;
|
|
serverState = apiServerState;
|
|
}
|
|
|
|
//AUTHENTICATE CREDS
|
|
//RETURN JWT
|
|
|
|
/// <summary>
|
|
/// Create credentials to receive a JSON web token
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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:
|
|
/// <code>Authorization: Bearer [TOKEN]</code>
|
|
/// Note the space between Bearer and the token. Also, do not include the square brackets
|
|
/// </remarks>
|
|
/// <param name="creds"></param>
|
|
/// <returns></returns>
|
|
[HttpPost]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> 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<string, object>()
|
|
{
|
|
//{ "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)
|
|
{
|
|
//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();
|
|
var UOpt = await ct.UserOptions.AsNoTracking().FirstAsync(z => z.UserId == u.Id);
|
|
|
|
List<string> TranslationKeysToFetch = new List<string> { "AuthTwoFactor", "AuthEnterPin", "AuthVerifyCode", "Cancel", "AuthPinInvalid" };
|
|
var LT = await TranslationBiz.GetSubsetStaticAsync(TranslationKeysToFetch, UOpt.TranslationId);
|
|
|
|
return Ok(ApiOkResponse.Response(new
|
|
{
|
|
AuthTwoFactor = LT["AuthTwoFactor"],
|
|
AuthEnterPin = LT["AuthEnterPin"],
|
|
AuthVerifyCode = LT["AuthVerifyCode"],
|
|
AuthPinInvalid = LT["AuthPinInvalid"],
|
|
Cancel = LT["Cancel"],
|
|
tfa = true,
|
|
tt = u.TempToken
|
|
}));
|
|
}
|
|
|
|
//Not 2fa, Valid password, user is authorized
|
|
return await ReturnUserCredsOnSuccessfulAuthentication(u);
|
|
|
|
|
|
}
|
|
}
|
|
|
|
//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));
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Verify tfa code
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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:
|
|
/// <code>Authorization: Bearer [TOKEN]</code>
|
|
/// Note the space between Bearer and the token. Also, do not include the square brackets
|
|
/// </remarks>
|
|
/// <param name="pin"></param>
|
|
/// <returns></returns>
|
|
[HttpPost("tfa-authenticate")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> 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<IActionResult> 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.OpsAdmin) &&
|
|
!u.Roles.HasFlag(Biz.AuthorizationRoles.OpsAdminRestricted))
|
|
{
|
|
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<string, object>()
|
|
{
|
|
// { "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 appropriate data for user type...
|
|
if (u.UserType == UserType.Customer | u.UserType == UserType.HeadOffice)
|
|
{
|
|
//customer type has special rights restrictions for UI features so return them here so client UI can enable or disable
|
|
var effectiveRights = await UserBiz.CustomerUserEffectiveRightsAsync(u.Id);
|
|
return Ok(ApiOkResponse.Response(new
|
|
{
|
|
token = token,
|
|
name = u.Name,
|
|
usertype = u.UserType,
|
|
roles = ((int)u.Roles).ToString(),
|
|
dlt = DownloadToken,
|
|
tfa = u.TwoFactorEnabled,
|
|
CustomerRights = effectiveRights
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
//Non customer user
|
|
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 -------------------------
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Change Password
|
|
/// </summary>
|
|
/// <param name="changecreds"></param>
|
|
/// <returns></returns>
|
|
[HttpPost("change-password")]
|
|
public async Task<IActionResult> 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
|
|
//Also it checks here that this is in fact the same user account calling this method as the user attempting to be modified
|
|
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
|
|
if (string.IsNullOrWhiteSpace(changecreds.OldPassword) || string.IsNullOrWhiteSpace(changecreds.LoginName))
|
|
{
|
|
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
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
|
|
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
|
}
|
|
|
|
//double check it's the currently logged in User's own User object only
|
|
//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(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
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(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Change Password via reset token
|
|
/// </summary>
|
|
/// <param name="resetcreds"></param>
|
|
/// <returns></returns>
|
|
[HttpPost("reset-password")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> 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));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(resetcreds.PasswordResetCode))
|
|
{
|
|
//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))
|
|
{
|
|
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
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
|
|
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
|
|
UserBiz biz = UserBiz.GetBiz(ct, HttpContext);
|
|
await biz.ChangePasswordAsync(user.Id, resetcreds.Password);
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate time limited password reset code for User
|
|
/// and email link to them so they can set their password
|
|
///
|
|
/// </summary>
|
|
/// <param name="id">User id</param>
|
|
/// <param name="apiVersion">From route path</param>
|
|
/// <returns>New concurrency code</returns>
|
|
[HttpPost("request-reset-password/{id}")]
|
|
public async Task<IActionResult> 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)
|
|
{
|
|
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
return BadRequest(new ApiErrorResponse(biz.Errors));
|
|
}
|
|
else
|
|
return Ok(ApiOkResponse.Response(new
|
|
{
|
|
concurrency = res
|
|
}));
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Generate TOTP secret and return for use in auth app
|
|
///
|
|
/// </summary>
|
|
/// <param name="apiVersion">From route path</param>
|
|
/// <returns>Authentication app activation code</returns>
|
|
[HttpGet("totp")]
|
|
public async Task<IActionResult> GenerateAndSendTOTP(ApiVersion apiVersion)
|
|
{
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
UserBiz biz = UserBiz.GetBiz(ct, HttpContext);
|
|
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
|
|
//get user and save the secret
|
|
var UserId = UserIdFromContext.Id(HttpContext.Items);
|
|
|
|
var u = await ct.User.FirstOrDefaultAsync(z => z.Id == UserId);
|
|
if (u == null)//should never happen but ?
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
|
|
//this is to stop someone from messing up someone's login accidentally or maliciously by simply hitting the route logged in as them
|
|
if (u.TwoFactorEnabled)
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "generalerror", "2fa already enabled"));
|
|
|
|
var tfa = new TwoFactorAuth("AyaNova");
|
|
u.TotpSecret = tfa.CreateSecret(160);
|
|
await ct.SaveChangesAsync();
|
|
|
|
//https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
|
//otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
|
|
//this format tested and works with Google, Microsoft Authy, Duo authenticators
|
|
string payload = $"otpauth://totp/AyaNova:{u.Name}?secret={u.TotpSecret}&issuer=AyaNova&algorithm=SHA1&digits=6&period=30";
|
|
|
|
QRCodeGenerator qrGenerator = new QRCodeGenerator();
|
|
QRCodeData qrCodeData = qrGenerator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q);
|
|
Base64QRCode qrCode = new Base64QRCode(qrCodeData);
|
|
string qrCodeImageAsBase64 = qrCode.GetGraphic(3);
|
|
|
|
return Ok(ApiOkResponse.Response(new
|
|
{
|
|
s = u.TotpSecret,
|
|
qr = qrCodeImageAsBase64
|
|
}));
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Confirm 2fa ready to use
|
|
///
|
|
/// </summary>
|
|
/// <param name="pin">Auth app 6 digit passcode</param>
|
|
/// <param name="apiVersion">From route path</param>
|
|
/// <returns>OK on success</returns>
|
|
[HttpPost("totp-validate")]
|
|
public async Task<IActionResult> ValidateTOTP([FromBody] TFAPinParam pin, ApiVersion apiVersion)
|
|
{
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
|
|
//get user
|
|
var UserId = UserIdFromContext.Id(HttpContext.Items);
|
|
|
|
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");
|
|
var ret = tfa.VerifyCode(u.TotpSecret, pin.Pin.Replace(" ", "").Trim());
|
|
if (ret == true)
|
|
{
|
|
//enable 2fa on user account
|
|
u.TwoFactorEnabled = true;
|
|
await ct.SaveChangesAsync();
|
|
}
|
|
else
|
|
{
|
|
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
|
|
}
|
|
|
|
return Ok(ApiOkResponse.Response(new
|
|
{
|
|
ok = ret
|
|
}));
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Disable (turn off) 2fa for user account
|
|
/// (For other user id requires full privileges)
|
|
/// </summary>
|
|
/// <param name="id">User id</param>
|
|
/// <param name="apiVersion">From route path</param>
|
|
/// <returns>OK on success</returns>
|
|
[HttpPost("totp-disable/{id}")]
|
|
public async Task<IActionResult> DisableTOTP([FromRoute] long id, ApiVersion apiVersion)
|
|
{
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
|
|
var UserId = UserIdFromContext.Id(HttpContext.Items);
|
|
if (id != UserId) //doing it on behalf of someone else
|
|
{
|
|
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.User))
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
}
|
|
|
|
|
|
var u = await ct.User.FirstOrDefaultAsync(z => z.Id == id);
|
|
if (u == null)//should never happen but ?
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
|
|
u.TotpSecret = null;
|
|
u.TempToken = null;
|
|
u.TwoFactorEnabled = false;
|
|
await ct.SaveChangesAsync();
|
|
return NoContent();
|
|
}
|
|
//------------------------------------------------------
|
|
|
|
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; }
|
|
}
|
|
|
|
public class TFAPinParam
|
|
{
|
|
[System.ComponentModel.DataAnnotations.Required]
|
|
public string Pin { get; set; }
|
|
public string TempToken { get; set; }
|
|
|
|
}
|
|
|
|
}//eoc
|
|
}//eons |