Files
raven/server/AyaNova/Controllers/AuthController.cs
2021-03-11 19:33:41 +00:00

521 lines
23 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)
{
//Valid password, user is effectively authorized at this point
//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<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 Ok(ApiOkResponse.Response(new
{
token = token,
name = u.Name,
usertype = u.UserType,
roles = ((int)u.Roles).ToString(),
dlt = DownloadToken,
tfa = u.TwoFactorEnabled
}));
}
}
//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>
/// 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));
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
await Task.Delay(nFailedAuthDelay);
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(nFailedAuthDelay);
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));
}
/// <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));
}
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();
}
/// <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)
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>New TOTP secret</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());
var tfa = new TwoFactorAuth("AyaNova");
u.TotpSecret = tfa.CreateSecret(160);
await ct.SaveChangesAsync();
//https://github.com/google/google-authenticator/wiki/Key-Uri-Format
QRCoder.PayloadGenerator.OneTimePassword generator = new QRCoder.PayloadGenerator.OneTimePassword()
{
Secret = u.TotpSecret,
Issuer = "AyaNova",
//Label = $"AyaNova.{u.Id}",
Type = QRCoder.PayloadGenerator.OneTimePassword.OneTimePasswordAuthType.TOTP
};
string payload = generator.ToString();
// QRCodeGenerator qrGenerator = new QRCodeGenerator();
// QRCodeData qrCodeData = qrGenerator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q);
// QRCode qrCode = new QRCode(qrCodeData);
// var qrCodeAsBitmap = qrCode.GetGraphic(20);
QRCodeGenerator qrGenerator = new QRCodeGenerator();
QRCodeData qrCodeData = qrGenerator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q);
Base64QRCode qrCode = new Base64QRCode(qrCodeData);
string qrCodeImageAsBase64 = qrCode.GetGraphic(4);
return Ok(ApiOkResponse.Response(new
{
s = u.TotpSecret,
qr = qrCodeImageAsBase64
}));
}
//------------------------------------------------------
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