8

In my API I have 2 endpoints, first that generates email to reset password form (I generate token using UserManager.GeneratePasswordResetTokenAsync).
Second endpoint is for actual password reset (I use UserManager.ResetPasswordAsync).

My requirement was to verify if token that is required for password reset isn't expired.

Searching over GitHub I found this issue and from what I found this isn't possible by design.

However searching deeper I've found that UserManager.ResetPasswordAsync internally uses ValidateAsync from Microsoft.AspNet.Identity.Owin.DataProtectorTokenProvider

Having this I've created this extension method:

using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using System;
using System.Globalization;
using System.IO;
using System.Text;

namespace Api.Extensions
{
    public enum TokenValidity
    {
        VALID,
        INVALID,
        INVALID_EXPIRED,
        ERROR
    }

    public static class UserManagerExtensions
    {
        public static TokenValidity IsResetPasswordTokenValid<TUser, TKey>(this UserManager<TUser, TKey> manager, TUser user, string token) where TKey : IEquatable<TKey> where TUser : class, IUser<TKey>
        {
            return IsTokenValid(manager, user, "ResetPassword", token);
        }

        public static TokenValidity IsTokenValid<TUser, TKey>(this UserManager<TUser, TKey> manager, TUser user, string purpose, string token) where TKey : IEquatable<TKey> where TUser : class, IUser<TKey>
        {
            try
            {
                //not sure if this is needed??
                if (!(manager.UserTokenProvider is DataProtectorTokenProvider<TUser, TKey> tokenProvider)) return TokenValidity.ERROR;

                var unprotectedData = tokenProvider.Protector.Unprotect(Convert.FromBase64String(token));
                var ms = new MemoryStream(unprotectedData);
                using (var reader = ms.CreateReader())
                {
                    var creationTime = reader.ReadDateTimeOffset();
                    var expirationTime = creationTime + tokenProvider.TokenLifespan;

                    var userId = reader.ReadString();
                    if (!String.Equals(userId, Convert.ToString(user.Id, CultureInfo.InvariantCulture)))
                    {
                        return TokenValidity.INVALID;
                    }

                    var purp = reader.ReadString();
                    if (!String.Equals(purp, purpose))
                    {
                        return TokenValidity.INVALID;
                    }

                    var stamp = reader.ReadString();
                    if (reader.PeekChar() != -1)
                    {
                        return TokenValidity.INVALID;
                    }

                    var expectedStamp = "";
                    //if supported get security stamp for user
                    if (manager.SupportsUserSecurityStamp)
                    {
                        expectedStamp = manager.GetSecurityStamp(user.Id);
                    }

                    if (!String.Equals(stamp, expectedStamp)) return TokenValidity.INVALID;

                    if (expirationTime < DateTimeOffset.UtcNow)
                    {
                        return TokenValidity.INVALID_EXPIRED;
                    }

                    return TokenValidity.VALID;
                }
            }
            catch
            {
                // Do not leak exception
            }
            return TokenValidity.INVALID;
        }
    }

    internal static class StreamExtensions
    {
        internal static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true);

        public static BinaryReader CreateReader(this Stream stream)
        {
            return new BinaryReader(stream, DefaultEncoding, true);
        }

        public static BinaryWriter CreateWriter(this Stream stream)
        {
            return new BinaryWriter(stream, DefaultEncoding, true);
        }

        public static DateTimeOffset ReadDateTimeOffset(this BinaryReader reader)
        {
            return new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero);
        }

        public static void Write(this BinaryWriter writer, DateTimeOffset value)
        {
            writer.Write(value.UtcTicks);
        }
    }
}

so now I'm able to add this check:

if (UserManager.IsResetPasswordTokenValid(user, model.Code) == TokenValidity.INVALID_EXPIRED)
{
    return this.BadRequest("errorResetingPassword", "Link expired");
}

My question are:

1.Is there an easier way of doing this?
My intention is to show user information that link in email has expired, because right now all he can see is that there was problem with resetting password.

2.If there isn't build in method of doing this what are the potential security vulnerabilities? I use my extension method as an additional check. If my method return true I still use ResetPasswordAsync.

Misiu
  • 4,738
  • 21
  • 94
  • 198

1 Answers1

7

The UserManager has VerifyUserTokenAsync and VerifyUserToken methods that you can use.

see Wouter's answer to the question "How can I check if a password reset token is expired?" for more details.

So you could use something like

if (!UserManager.VerifyUserToken(userId, "ResetPassword", model.code)){
  return this.BadRequest("errorResetingPassword", "Link expired");
}
J Fay
  • 71
  • 3
  • 1
    I've tried this, but the problem is that it checks all the things: creationDate, userId, purpose and security stamp. After calling `VerifyUserToken` You will know that token is invalid, You won't know why it is invalid. I want to explicitly know that userId is valid, purpose is valid, security stamp is valid but token is expired. Hope that makes sense :) – Misiu Jul 26 '18 at 09:48
  • I've updated my code a bit, now I check user id, purpose and security stamp before I check token expiration. – Misiu Jul 26 '18 at 10:27
  • Hey @Misiu, your extension seems to fit my purpose. I read you updated the code so : is the code from the original post up to date with checking for user id, purpose and stamp? – j0w May 16 '19 at 14:10
  • @j0w yes, I think it is up to date :) I've used in a couple of projects and had no problem with it. I only use it to show the correct message to the user - I display `the link is expired` or `the link is invalid`. I'm not a security expert, so always use built-in methods to verify token - First I call my method, if it returns `VALID` then I call the built-in method again (to be super sure it is valid) – Misiu May 17 '19 at 07:24