Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for parsing an Uri to OtpUri #67

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 125 additions & 10 deletions src/Otp.NET/OtpUri.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
using System;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;

namespace OtpNet;

// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
public class OtpUri
{
private const OtpHashMode DEFAULT_HASH_MODE = OtpHashMode.Sha1;
private const int DEFAULT_DIGITS = 6;
private const int DEFAULT_PERIOD = 30;
private const int DEFAULT_COUNTER = 0;
private const string SCHEME = "otpauth";
private static readonly Regex queryParameterRegex = new(@"[?&](\w[\w.]*)=([^?&]+)");
private static readonly Regex accountAndIssuerRegex = new("^/(?:([^:]+):)? *([^:]+)$");

private delegate bool ParseNumber<T>(string s, System.Globalization.NumberStyles style, IFormatProvider provider, out T result);

/// <summary>
/// Create a new OTP Auth Uri
/// </summary>
Expand All @@ -15,10 +26,10 @@ public OtpUri(
string secret,
string user,
string issuer = null,
OtpHashMode algorithm = OtpHashMode.Sha1,
int digits = 6,
int period = 30,
long counter = 0)
OtpHashMode algorithm = DEFAULT_HASH_MODE,
int digits = DEFAULT_DIGITS,
int period = DEFAULT_PERIOD,
long counter = DEFAULT_COUNTER)
{
_ = secret ?? throw new ArgumentNullException(nameof(secret));
_ = user ?? throw new ArgumentNullException(nameof(user));
Expand Down Expand Up @@ -53,14 +64,117 @@ public OtpUri(
byte[] secret,
string user,
string issuer = null,
OtpHashMode algorithm = OtpHashMode.Sha1,
int digits = 6,
int period = 30,
long counter = 0)
OtpHashMode algorithm = DEFAULT_HASH_MODE,
int digits = DEFAULT_DIGITS,
int period = DEFAULT_PERIOD,
long counter = DEFAULT_COUNTER)
: this(schema, Base32Encoding.ToString(secret), user, issuer,
algorithm, digits, period, counter)
{ }

public OtpUri(string uri)
: this(new Uri(uri))
{ }

public OtpUri(Uri uri)
{
_ = uri ?? throw new ArgumentNullException(nameof(uri));

if (uri.Scheme != SCHEME)
{
throw new ArgumentException($"Uri must use scheme {SCHEME}", nameof(uri));
}

T? DetermineEnum<T>(string str) where T : struct, Enum
{
foreach (T type in Enum.GetValues(typeof(T)))
{
string typeString = type.ToString();
if (typeString.Equals(str, StringComparison.InvariantCultureIgnoreCase))
{
return type;
}
}
return null;
}

void Parse<T>(string key, string value, ref T? result, ParseNumber<T> parse) where T : struct
{
if (result.HasValue) throw new ArgumentException($"Uri supplies '{key}' parameter multiple times", nameof(uri));
if (!parse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var parsedResult))
{
throw new ArgumentException($"Uri '{key}' parameter '{value}' is not a valid integer", nameof(uri));
}
result = parsedResult;
}

OtpType? determinedType = DetermineEnum<OtpType>(uri.Authority);
if (!determinedType.HasValue) throw new ArgumentException("Uri uses no known type", nameof(uri));
Type = determinedType.Value;

// Contains the leading path delimiter
var accountAndIssuerMatch = accountAndIssuerRegex.Match(uri.LocalPath);
if (accountAndIssuerMatch.Success)
{
Group issuerGroup = accountAndIssuerMatch.Groups[1];
Issuer = issuerGroup.Success ? issuerGroup.Value : null;
User = accountAndIssuerMatch.Groups[2].Value;
}

// Parse query parameters
OtpHashMode? algorithm = null;
int? digits = null;
long? counter = null;
int? period = null;

var queryParameterMatch = queryParameterRegex.Match(uri.Query);
while (queryParameterMatch.Success)
{
string key = queryParameterMatch.Groups[1].Value.ToLower();
string value = Uri.UnescapeDataString(queryParameterMatch.Groups[2].Value);

switch (key)
{
case "secret":
Secret = value;
break;
case "issuer":
if (Issuer != null && Issuer != value) throw new ArgumentException($"Uri supplies different issuers in label ({Issuer}) and parameter ({value})", nameof(uri));
Issuer = value;
break;
case "algorithm":
if (algorithm.HasValue) throw new ArgumentException("Uri supplies 'algorithm' parameter multiple times", nameof(uri));
algorithm = DetermineEnum<OtpHashMode>(value);
if (!algorithm.HasValue) throw new ArgumentException($"Uri 'algorithm' parameter '{value}' uses no known algorithm", nameof(uri));
break;
case "digits":
Parse(key, value, ref digits, int.TryParse);
break;
case "counter":
if (Type != OtpType.Hotp) throw new ArgumentException($"Uri 'counter' parameter is not valid for type '{Type}'", nameof(uri));
Parse(key, value, ref counter, long.TryParse);
break;
case "period":
if (Type != OtpType.Totp) throw new ArgumentException($"Uri 'period' parameter is not valid for type '{Type}'", nameof(uri));
Parse(key, value, ref period, int.TryParse);
break;
default:
throw new ArgumentException($"Unknown parameter '{key}' in query string of uri", nameof(uri));
}

queryParameterMatch = queryParameterMatch.NextMatch();
}

if (Secret == null) throw new ArgumentException($"Uri didn't provide the mandatory parameter 'secret'");
// throws when Secret does contain invalid characters
_ = Base32Encoding.ToBytes(Secret);

Algorithm = algorithm ?? DEFAULT_HASH_MODE;
Digits = digits ?? DEFAULT_DIGITS;
Period = period ?? DEFAULT_PERIOD;
Counter = counter ?? DEFAULT_COUNTER;
}

/// <summary>
/// What type of OTP is this uri for
/// <seealso cref="OtpType"/>
Expand Down Expand Up @@ -141,7 +255,8 @@ public override string ToString()
break;
}

var uriBuilder = new StringBuilder("otpauth://");
var uriBuilder = new StringBuilder(SCHEME);
uriBuilder.Append("://");
uriBuilder.Append(Type.ToString().ToLowerInvariant());
uriBuilder.Append("/");

Expand Down
58 changes: 58 additions & 0 deletions test/Otp.NET.Test/OtpUriTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,63 @@ public void GenerateOtpUriTest(string secret, OtpType otpType, string user, stri
{
var uriString = new OtpUri(otpType, secret, user, issuer, hash, digits, period, counter).ToString();
Assert.That(uriString, Is.EqualTo(expectedUri));

var parsedOtpUri = new OtpUri(expectedUri);
Assert.That(parsedOtpUri.Secret, Is.EqualTo(secret));
Assert.That(parsedOtpUri.Type, Is.EqualTo(otpType));
Assert.That(parsedOtpUri.User, Is.EqualTo(user));
Assert.That(parsedOtpUri.Issuer, Is.EqualTo(issuer));
Assert.That(parsedOtpUri.Algorithm, Is.EqualTo(hash));
Assert.That(parsedOtpUri.Digits, Is.EqualTo(digits));
Assert.That(parsedOtpUri.Period, Is.EqualTo(period));
Assert.That(parsedOtpUri.Counter, Is.EqualTo(counter));
}

[TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0,
"otpauth://totp/ACME%20Co:%20alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")]
[TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0,
"otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP")]
[TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0,
"otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30")]
[TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0,
"otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")]
[TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0,
"otpauth://totp/ACME%20Co%3Aalice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")]
public void ParseOtpUriTest(string expectedSecret, OtpType expectedOtpType, string expectedUser, string expectedIssuer,
OtpHashMode expectedHash, int expectedDigits, int expectedPeriod, int expectedCounter, string uri)
{
var parsedOtpUri = new OtpUri(uri);
Assert.That(parsedOtpUri.Secret, Is.EqualTo(expectedSecret));
Assert.That(parsedOtpUri.Type, Is.EqualTo(expectedOtpType));
Assert.That(parsedOtpUri.User, Is.EqualTo(expectedUser));
Assert.That(parsedOtpUri.Issuer, Is.EqualTo(expectedIssuer));
Assert.That(parsedOtpUri.Algorithm, Is.EqualTo(expectedHash));
Assert.That(parsedOtpUri.Digits, Is.EqualTo(expectedDigits));
Assert.That(parsedOtpUri.Period, Is.EqualTo(expectedPeriod));
Assert.That(parsedOtpUri.Counter, Is.EqualTo(expectedCounter));
}

[TestCase("http://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // invalid scheme
[TestCase("otpauth://invalid/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // invalid type
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Different&algorithm=SHA1&digits=6&period=30")] // different issuers
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // missing secret
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=1IsInvalid&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // invalid secret
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=Invalid&digits=6&period=30")] // invalid algorithm
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=invalid&period=30")] // invalid digits
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=-1&period=30")] // negative digits
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=invalid")] // invalid period
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=-1")] // negative period
[TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30&counter=0")] // counter with totp
[TestCase("otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // period with htop
[TestCase("otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&counter=invalid")] // invalid counter
[TestCase("otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&counter=-1")] // negative counter
public void ParseInvalidOtpUriTest(string uri)
{
void Constructor()
{
var _ = new OtpUri(uri);
}

Assert.Throws<System.ArgumentException>(Constructor);
}
}