Skip to content

Commit 232174e

Browse files
committed
Add optional TOTP-based 2FA login
New TwoFactorAuth.cs with RFC 6238 TOTP (no deps), config under <Security> section, integrated into both entry points
1 parent d02f4b9 commit 232174e

5 files changed

Lines changed: 243 additions & 5 deletions

File tree

SPOtoSQL-Net8/ConsoleApp1Net8/ConsoleApp1Net8.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\Sharepoint\SPOList.cs" />
7474
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\Sharepoint\SPOUser.cs" />
7575
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\Sharepoint\TimesheetDQ.cs" />
76+
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\Sharepoint\TwoFactorAuth.cs" />
7677
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\XmlConfig\ConfigHelper.cs" />
7778
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\ConsoleLogger\Logger.cs" />
7879
<Compile Include="..\..\SPOtoSQL-Snapshots\ConsoleApp1\AssemblyInfo.cs" />

SPOtoSQL-Snapshots/ConsoleApp1/SPODataQuality/RefreshSPOLists.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ private static void RunMainWorkflow()
7777
throw new InvalidOperationException("Failed to retrieve SharePoint credentials from configuration");
7878
}
7979

80+
if (!TwoFactorAuth.PerformVerification())
81+
{
82+
Console.WriteLine("2FA verification failed. Exiting.");
83+
Environment.Exit(1);
84+
}
85+
8086
using (var spoUser = new SPOUser(credentials.Username, credentials.Password))
8187
{
8288
Logger.Log(1, "DEBUG: SPOUser created");
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
using System;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
5+
namespace Bring.Sharepoint
6+
{
7+
public static class TwoFactorAuth
8+
{
9+
private const string APP_NAME = "SPO2SQL";
10+
private const int TIME_STEP = 30;
11+
private const int CODE_DIGITS = 6;
12+
13+
public static bool IsEnabled => ConfigurationReader.IsTwoFactorEnabled();
14+
15+
public static bool PerformVerification()
16+
{
17+
if (!IsEnabled) return true;
18+
19+
string secret = ConfigurationReader.GetTwoFactorSecret();
20+
21+
if (string.IsNullOrEmpty(secret))
22+
{
23+
return RunSetup();
24+
}
25+
26+
return RunVerification(secret);
27+
}
28+
29+
private static bool RunSetup()
30+
{
31+
string secret = GenerateSecret();
32+
33+
Console.WriteLine();
34+
Console.WriteLine("=== Two-Factor Authentication Setup ===");
35+
Console.WriteLine("Scan the QR code or enter the secret key manually:");
36+
Console.WriteLine();
37+
Console.WriteLine(" Secret: " + FormatSecret(secret));
38+
Console.WriteLine(" App: " + APP_NAME);
39+
Console.WriteLine();
40+
Console.WriteLine(" otpauth://totp/" + APP_NAME + "?secret=" + secret + "&issuer=" + APP_NAME);
41+
Console.WriteLine();
42+
Console.Write("Enter the 6-digit code from your authenticator app: ");
43+
string? code = Console.ReadLine()?.Trim();
44+
45+
if (!string.IsNullOrEmpty(code) && ValidateCode(secret, code))
46+
{
47+
Console.WriteLine("Verification successful!");
48+
Console.WriteLine();
49+
Console.WriteLine("Add the following to your UserConfig.xml inside <Security> section:");
50+
Console.WriteLine(" <TwoFactorSecret>" + secret + "</TwoFactorSecret>");
51+
Console.WriteLine("2FA is now active.");
52+
return true;
53+
}
54+
55+
Console.WriteLine("Invalid code. Setup aborted.");
56+
return false;
57+
}
58+
59+
private static bool RunVerification(string secret)
60+
{
61+
for (int attempts = 0; attempts < 3; attempts++)
62+
{
63+
Console.Write("Enter 2FA code: ");
64+
string? code = Console.ReadLine()?.Trim();
65+
66+
if (!string.IsNullOrEmpty(code) && ValidateCode(secret, code))
67+
{
68+
return true;
69+
}
70+
71+
if (attempts < 2)
72+
Console.WriteLine("Invalid code. Try again.");
73+
}
74+
75+
Console.WriteLine("Too many invalid attempts.");
76+
return false;
77+
}
78+
79+
public static string GenerateCode(string secretBase32)
80+
{
81+
byte[] secret = Base32Decode(secretBase32);
82+
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / TIME_STEP;
83+
return ComputeTotp(secret, counter);
84+
}
85+
86+
public static bool ValidateCode(string secretBase32, string code)
87+
{
88+
byte[] secret = Base32Decode(secretBase32);
89+
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / TIME_STEP;
90+
91+
for (int i = -1; i <= 1; i++)
92+
{
93+
if (ComputeTotp(secret, counter + i) == code)
94+
return true;
95+
}
96+
97+
return false;
98+
}
99+
100+
private static string ComputeTotp(byte[] secret, long counter)
101+
{
102+
byte[] counterBytes = BitConverter.GetBytes(counter);
103+
if (BitConverter.IsLittleEndian)
104+
Array.Reverse(counterBytes);
105+
106+
using var hmac = new HMACSHA1(secret);
107+
byte[] hash = hmac.ComputeHash(counterBytes);
108+
109+
int offset = hash[^1] & 0xf;
110+
int binary = ((hash[offset] & 0x7f) << 24)
111+
| ((hash[offset + 1] & 0xff) << 16)
112+
| ((hash[offset + 2] & 0xff) << 8)
113+
| (hash[offset + 3] & 0xff);
114+
115+
int otp = binary % (int)Math.Pow(10, CODE_DIGITS);
116+
return otp.ToString().PadLeft(CODE_DIGITS, '0');
117+
}
118+
119+
public static string GenerateSecret()
120+
{
121+
byte[] random = new byte[20];
122+
using var rng = RandomNumberGenerator.Create();
123+
rng.GetBytes(random);
124+
return Base32Encode(random);
125+
}
126+
127+
private static string Base32Encode(byte[] data)
128+
{
129+
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
130+
var result = new StringBuilder();
131+
132+
int buffer = 0;
133+
int bitsInBuffer = 0;
134+
135+
foreach (byte b in data)
136+
{
137+
buffer = (buffer << 8) | b;
138+
bitsInBuffer += 8;
139+
140+
while (bitsInBuffer >= 5)
141+
{
142+
bitsInBuffer -= 5;
143+
int index = (buffer >> bitsInBuffer) & 0x1f;
144+
result.Append(alphabet[index]);
145+
}
146+
}
147+
148+
if (bitsInBuffer > 0)
149+
{
150+
buffer <<= (5 - bitsInBuffer);
151+
result.Append(alphabet[buffer & 0x1f]);
152+
}
153+
154+
return result.ToString();
155+
}
156+
157+
private static byte[] Base32Decode(string input)
158+
{
159+
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
160+
input = input.Trim().ToUpperInvariant().Replace(" ", "").Replace("-", "");
161+
162+
int bitBuffer = 0;
163+
int bitsInBuffer = 0;
164+
var bytes = new System.Collections.Generic.List<byte>();
165+
166+
foreach (char c in input)
167+
{
168+
int value = alphabet.IndexOf(c);
169+
if (value < 0) continue;
170+
171+
bitBuffer = (bitBuffer << 5) | value;
172+
bitsInBuffer += 5;
173+
174+
if (bitsInBuffer >= 8)
175+
{
176+
bitsInBuffer -= 8;
177+
bytes.Add((byte)((bitBuffer >> bitsInBuffer) & 0xff));
178+
}
179+
}
180+
181+
return bytes.ToArray();
182+
}
183+
184+
private static string FormatSecret(string secret)
185+
{
186+
var sb = new StringBuilder();
187+
for (int i = 0; i < secret.Length; i++)
188+
{
189+
if (i > 0 && i % 4 == 0) sb.Append(' ');
190+
sb.Append(secret[i]);
191+
}
192+
return sb.ToString();
193+
}
194+
}
195+
}

SPOtoSQL-Snapshots/ConsoleApp1/Sqlserver/RefreshSQLLists.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ public static void SPOtoSQLUpdate(bool daily)
2424
var (username, password) = ConfigurationReader.GetSharePointCredentials();
2525
Logger.Log(1, $"SPOtoSQLUpdate: Username: {username}");
2626

27+
if (!TwoFactorAuth.PerformVerification())
28+
{
29+
Console.WriteLine("2FA verification failed. Exiting.");
30+
return;
31+
}
32+
2733
SPOUser user;
2834
try
2935
{

SPOtoSQL-Snapshots/ConsoleApp1/XmlConfig/ConfigHelper.cs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,41 @@ public static string GetSqlConnectionString()
258258
}
259259
}
260260

261-
/// <summary>
262-
/// Retrieves the SharePoint base URL from the configuration file.
263-
/// Returns null if not configured, allowing for a default fallback URL.
264-
/// </summary>
265-
/// <returns>The SharePoint base URL (e.g., https://tenant.sharepoint.com), or null if not configured.</returns>
261+
public static bool IsTwoFactorEnabled()
262+
{
263+
LoadConfig();
264+
265+
try
266+
{
267+
var secNode = _xmlDoc.SelectSingleNode("//Configuration/Security");
268+
if (secNode == null) return false;
269+
270+
var enabledNode = secNode.SelectSingleNode("TwoFactorEnabled");
271+
if (enabledNode == null) return false;
272+
273+
return bool.TryParse(enabledNode.InnerText.Trim(), out bool result) && result;
274+
}
275+
catch
276+
{
277+
return false;
278+
}
279+
}
280+
281+
public static string GetTwoFactorSecret()
282+
{
283+
LoadConfig();
284+
285+
try
286+
{
287+
var secNode = _xmlDoc.SelectSingleNode("//Configuration/Security");
288+
return secNode?.SelectSingleNode("TwoFactorSecret")?.InnerText.Trim() ?? string.Empty;
289+
}
290+
catch
291+
{
292+
return string.Empty;
293+
}
294+
}
295+
266296
public static string GetSharePointBaseUrl()
267297
{
268298
LoadConfig();

0 commit comments

Comments
 (0)