Files
raven/server/AyaNova/Controllers/AuthController.cs
2020-06-10 14:40:12 +00:00

352 lines
15 KiB
C#

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 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")]
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]
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));
}
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<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 = "Manager Account - TESTING",
roles = "0"
}));
}
#endregion testing
#endif
if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password))
{
//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(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* manager account can login now
if(serverState.IsClosed){
//if not manager account then boot closed
//manager 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;
await ct.SaveChangesAsync();
log.LogDebug($"User number \"{u.Id}\" logged in from \"{Util.StringUtil.MaskIPAddress(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(nFailedAuthDelay);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
}
/// <summary>
/// Change Password
/// </summary>
/// <param name="changecreds"></param>
/// <returns></returns>
[HttpPost("changepassword")]
public async Task<IActionResult> 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))
{
//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));
}
//------------------------------------------------------
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