Securely allowing one-time access to a section of

2019-06-09 02:08发布

问题:

Part of an app i'm building requires that admin users can let an employee access one page of the app to perform a task. After the employee has completed that task, they have no reason to return to the app.

This app is hosted online and so the employee access needs to be secured with a logon.

My question is, what is the best approach regarding providing a login account to a user who would only use the system once?

As I see it, I have two options:

  1. Provide the admin users with one permanent login account for employees, which can be re-used for each employee (i would need to provide each employee with an extra passcode so that the system can look it up and see who they really are)

  2. Create a login account for each employee as and when they need access, and then delete the login account after it has been used. For this username I would concatenate a common word (company name for example) with a unique id (possibly the id of their task)

Option 2 seems to make the most sense in terms of security. Are there any pitfalls with this approach, or are there any alternative solutions?

回答1:

Personally, I would consider a third option: create a parallel access control table for this page. In other words, you'd have something like:

public class PageAccess
{
    public string Email { get; set; }
    public string Token { get; set; }
    public DateTime Expiration { get; set; }
}

When an admin wants to grant access to the page, they would give the email of the user who should have access (Email). A random token would then be generated (saved hashed as Token). Then the user would be sent an email at their email address with a URL to the page which would include a parameter composed of the email address and token, and then base 64 encoded.

Upon clicking the link the user would be taken to the page, where first, the parameter would be validated: base 64 decode, split email and token, lookup the access record by email, hash token and compare to stored token, and (optionally) compare the expiration date with now (so that you can keep people from trying to access a URL from an email sent months or years ago).

If everything is kosher, the user is shown the page. When they complete whatever action they need to make, you delete the access record.

This is essentially the same process employed by a password reset, only here, you're just using it to grant one-time access instead of allowing them to change their password.

UPDATE

The following is a utility class that I use. I'm not a security expert, but I did some extensive reading and borrowed heavily from StackExchange code I found at some point, somewhere, which either doesn't exist publicly anymore, or evades my search skills.

using System;
using System.Security.Cryptography;
using System.Text;

public static class CryptoUtil
{
    // The following constants may be changed without breaking existing hashes.
    public const int SaltBytes = 32;
    public const int HashBytes = 32;
    public const int Pbkdf2Iterations = /* Some int here. Larger is better, but also slower. Something in the range of 1000-2000 works well. Don't expose this value. */;

    public const int IterationIndex = 0;
    public const int SaltIndex = 1;
    public const int Pbkdf2Index = 2;

    /// <summary>
    /// Creates a salted PBKDF2 hash of the password.
    /// </summary>
    /// <param name="password">The password to hash.</param>
    /// <returns>The hash of the password.</returns>
    public static string CreateHash(string password)
    {
        // TODO: Raise exception is password is null
        // Generate a random salt
        RNGCryptoServiceProvider csprng = new RNGCryptoServiceProvider();
        byte[] salt = new byte[SaltBytes];
        csprng.GetBytes(salt);

        // Hash the password and encode the parameters
        byte[] hash = PBKDF2(password, salt, Pbkdf2Iterations, HashBytes);
        return Pbkdf2Iterations.ToString("X") + ":" +
            Convert.ToBase64String(salt) + ":" +
            Convert.ToBase64String(hash);
    }

    /// <summary>
    /// Validates a password given a hash of the correct one.
    /// </summary>
    /// <param name="password">The password to check.</param>
    /// <param name="goodHash">A hash of the correct password.</param>
    /// <returns>True if the password is correct. False otherwise.</returns>
    public static bool ValidateHash(string password, string goodHash)
    {
        // Extract the parameters from the hash
        char[] delimiter = { ':' };
        string[] split = goodHash.Split(delimiter);
        int iterations = Int32.Parse(split[IterationIndex], System.Globalization.NumberStyles.HexNumber);
        byte[] salt = Convert.FromBase64String(split[SaltIndex]);
        byte[] hash = Convert.FromBase64String(split[Pbkdf2Index]);

        byte[] testHash = PBKDF2(password, salt, iterations, hash.Length);
        return SlowEquals(hash, testHash);
    }

    /// <summary>
    /// Compares two byte arrays in length-constant time. This comparison
    /// method is used so that password hashes cannot be extracted from
    /// on-line systems using a timing attack and then attacked off-line.
    /// </summary>
    /// <param name="a">The first byte array.</param>
    /// <param name="b">The second byte array.</param>
    /// <returns>True if both byte arrays are equal. False otherwise.</returns>
    private static bool SlowEquals(byte[] a, byte[] b)
    {
        uint diff = (uint)a.Length ^ (uint)b.Length;
        for (int i = 0; i < a.Length && i < b.Length; i++)
            diff |= (uint)(a[i] ^ b[i]);
        return diff == 0;
    }

    /// <summary>
    /// Computes the PBKDF2-SHA1 hash of a password.
    /// </summary>
    /// <param name="password">The password to hash.</param>
    /// <param name="salt">The salt.</param>
    /// <param name="iterations">The PBKDF2 iteration count.</param>
    /// <param name="outputBytes">The length of the hash to generate, in bytes.</param>
    /// <returns>A hash of the password.</returns>
    private static byte[] PBKDF2(string password, byte[] salt, int iterations, int outputBytes)
    {
        Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(password, salt);
        pbkdf2.IterationCount = iterations;
        return pbkdf2.GetBytes(outputBytes);
    }

    public static string GetUniqueKey(int length)
    {
        char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890".ToCharArray();
        byte[] bytes = new byte[length];
        using (var rng = new RNGCryptoServiceProvider())
        {
            rng.GetNonZeroBytes(bytes);
        }
        var result = new StringBuilder(length);
        foreach (byte b in bytes)
        {
            result.Append(chars[b % (chars.Length - 1)]);
        }
        return result.ToString();
    }

    public static string Base64Encode(string str)
    {
        return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(str));
    }

    public static string Base64Decode(string str)
    {
        return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(str));
    }

    public static string Base64EncodeGuid(Guid guid)
    {
        return Convert.ToBase64String(guid.ToByteArray());
    }

    public static Guid Base64DecodeGuid(string str)
    {
        return new Guid(Convert.FromBase64String(str));
    }
}

Then, I do something like the following for generating password resets:

var token = CryptoUtil.GetUniqueKey(16);
var hashedToken = CryptoUtil.CreateHash(token);
var emailToken = CryptoUtil.Base64Encode(string.Format("{0}:{1}", email, token));

The hashedToken variable gets stored in your database, while emailToken is what is put in the URL that is sent to your user. On the action that handles the URL:

var parts = CryptoUtil.Base64Decode(emailToken).Split(':');
var email = parts[0];
var token = parts[1];

Look up the record using email. Then compare using:

CryptoUtil.ValidateHash(token, hashedTokenFromDatabase)