ASP.NET Identity verify if ResetPassword token has expired
Asked Answered
L

1

9

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.

Lightman answered 25/7, 2018 at 12:39 Comment(0)
K
8

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");
}
Kenna answered 26/7, 2018 at 8:52 Comment(4)
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 :)Lightman
I've updated my code a bit, now I check user id, purpose and security stamp before I check token expiration.Lightman
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?Alcaic
@Alcaic 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)Lightman

© 2022 - 2024 — McMap. All rights reserved.