Skip to content

Commit

Permalink
feat: Implement OTP Store and OTP expiry
Browse files Browse the repository at this point in the history
  • Loading branch information
sitaram-kalluri committed Oct 6, 2023
1 parent 4686e7d commit af10334
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 35 deletions.
8 changes: 7 additions & 1 deletion packages/at_secondary_server/config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,10 @@ enrollment:
# The maximum number of requests allowed within the time window.
maxRequestsPerTimeFrame: 5
# The duration of the time window in hours.
timeFrameInHours: 1
timeFrameInHours: 1

# OTP Store Configurations
otp:
# The duration between each garbage collection which removes the expired OTPs from the OTPStore.
# Defaults to 60 minutes
gcDurationInMins: 60
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class AtSecondaryConfig {
static const int _enrollmentExpiryInHours = 48;
static int _maxEnrollRequestsAllowed = 5;

// OTP Configurations
// The duration between each garbage collection which removes the expired OTPs from the OTPStore.
static const int _otpGCDurationInMins = 60;

static final int _timeFrameInHours = 1;

// For easy of testing, duration in hours is long. Hence introduced "timeFrameInMills"
Expand Down Expand Up @@ -770,6 +774,18 @@ class AtSecondaryConfig {
}
}

static int get otpGCDurationInMins {
var result = _getIntEnvVar('otpGCDurationInMins');
if (result != null) {
return result;
}
try {
return getConfigFromYaml(['otp', 'gcDurationInMins']);
} on ElementNotFoundException {
return _otpGCDurationInMins;
}
}

static set timeFrameInMills(int timeWindowInMills) {
_timeFrameInMills = timeWindowInMills;
}
Expand Down
73 changes: 73 additions & 0 deletions packages/at_secondary_server/lib/src/store/otp_store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:collection';

import 'package:meta/meta.dart';

/// A class for managing One-Time Passwords (OTPs) using a linked hash map.
///
/// This class allows you to store OTPs along with their expiration times and
/// periodically remove expired OTPs.
///
/// The [gcDuration] parameter determines the duration between each garbage
/// collection cycle. Expired OTPs are removed from the store during garbage
/// collection.
class OTPStore {
/// The duration between each garbage collection. Default 60 minutes.
final Duration gcDuration;

/// The internal store for OTPs and their expiration times.
final LinkedHashMap<String, int> _otpStore = LinkedHashMap();

/// Creates an instance of the OTPStore class.
///
/// The [gcDuration] parameter specifies the duration between each garbage
/// collection cycle. During garbage collection, expired OTPs are removed
/// from the store.
///
/// By default, [gcDuration] is set to 60 minutes.
OTPStore({this.gcDuration = const Duration(minutes: 60)}) {
// Initialize a timer for periodic garbage collection.
Timer.periodic(gcDuration, (Timer t) => _removeExpiredOTPs());
}

/// Retrieves the duration in millisecondsSinceEpoch associated with the given [key].
///
/// Returns `null` if the [key] is not found in the OTP store
int? get(String key) {
return _otpStore[key];
}

/// Sets the OTP associated with the given [key] along with its expiration time.
///
/// The [key] is typically the OTP value itself, and [expiryDurationSinceEpoch]
/// represents the expiration time for the OTP as a duration since the epoch.
void set(String key, int expiryDurationSinceEpoch) {
_otpStore[key] = expiryDurationSinceEpoch;
}

/// Removes the OTP associated with the given [key] from the store.
///
/// If the [key] is not found in the OTP store, this method has no effect.
void remove(String key) {
_otpStore.remove(key);
}

/// Removes expired OTPs from the store.
///
/// This method is automatically called at regular intervals based on the
/// [gcDuration] specified during object creation.
void _removeExpiredOTPs() {
_otpStore.removeWhere(
(otp, expiryInMills) =>
expiryInMills <= DateTime
.now()
.toUtc()
.millisecondsSinceEpoch
);
}

@visibleForTesting
int size() {
return _otpStore.length;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,12 @@ class EnrollVerbHandler extends AbstractVerbHandler {
throw AtThrottleLimitExceeded(
'Enrollment requests have exceeded the limit within the specified time frame');
}
if (!atConnection.getMetaData().isAuthenticated) {
var otp = enrollParams.otp;
if (otp == null ||
(await OtpVerbHandler.cache.get(otp.toString()) == null)) {
throw AtEnrollmentException(
'invalid otp. Cannot process enroll request');
}

if ((!atConnection.getMetaData().isAuthenticated) &&
(!OtpVerbHandler.isValidOTP(enrollParams.otp))) {
throw AtEnrollmentException('invalid otp. Cannot process enroll request');
}

var enrollNamespaces = enrollParams.namespaces ?? {};
var newEnrollmentId = Uuid().v4();
var key =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
import 'dart:collection';
import 'dart:math';
import 'package:at_commons/at_commons.dart';
import 'package:at_secondary/src/server/at_secondary_config.dart';
import 'package:at_secondary/src/store/otp_store.dart';
import 'package:at_server_spec/at_server_spec.dart';
import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';
import 'abstract_verb_handler.dart';
import 'package:at_server_spec/at_verb_spec.dart';
import 'package:at_persistence_secondary_server/at_persistence_secondary_server.dart';
import 'package:expire_cache/expire_cache.dart';

class OtpVerbHandler extends AbstractVerbHandler {
static Otp otpVerb = Otp();
static final expireDuration = Duration(seconds: 90);
static ExpireCache<String, String> cache =
ExpireCache<String, String>(expireDuration: expireDuration);

OtpVerbHandler(SecondaryKeyStore keyStore) : super(keyStore);
@visibleForTesting
Duration otpExpiryDuration = Duration(minutes: 5);

static late OTPStore _otpStore;

OtpVerbHandler(SecondaryKeyStore keyStore, {Duration? gcDuration})
: super(keyStore) {
gcDuration ??= Duration(minutes: AtSecondaryConfig.otpGCDurationInMins);
_otpStore = OTPStore(gcDuration: gcDuration);
}

@override
bool accept(String command) =>
command == 'otp:get' || command.startsWith('otp:validate');
bool accept(String command) => command.startsWith('otp');

@override
Verb getVerb() => otpVerb;
Expand All @@ -29,6 +36,10 @@ class OtpVerbHandler extends AbstractVerbHandler {
HashMap<String, String?> verbParams,
InboundConnection atConnection) async {
final operation = verbParams['operation'];
if (verbParams[AtConstants.ttl] != null) {
otpExpiryDuration =
Duration(seconds: int.parse(verbParams[AtConstants.ttl]!));
}
switch (operation) {
case 'get':
if (!atConnection.getMetaData().isAuthenticated) {
Expand All @@ -40,17 +51,36 @@ class OtpVerbHandler extends AbstractVerbHandler {
}
// If OTP generated do not have digits, generate again.
while (RegExp(r'\d').hasMatch(response.data!) == false);
await cache.set(response.data!, response.data!);
_otpStore.set(
response.data!,
DateTime.now()
.toUtc()
.add(otpExpiryDuration)
.millisecondsSinceEpoch);
break;
case 'validate':
String? otp = verbParams['otp'];
if (otp != null && (await cache.get(otp)) == otp) {
bool isValid = isValidOTP(otp);
if (isValid) {
response.data = 'valid';
} else {
response.data = 'invalid';
return;
}
break;
response.data = 'invalid';
}
}

static bool isValidOTP(String? otp) {
if (otp == null) {
return false;
}
int? otpExpiry = _otpStore.get(otp);
// Remove the OTP from the OTPStore to prevent reuse of OTP.
_otpStore.remove(otp);
if (otpExpiry != null &&
otpExpiry >= DateTime.now().toUtc().millisecondsSinceEpoch) {
return true;
}
return false;
}

/// This function generates a UUID and converts it into a 6-character alpha-numeric string.
Expand Down Expand Up @@ -100,4 +130,14 @@ class OtpVerbHandler extends AbstractVerbHandler {
}
return result;
}

/// Retrieves the number of OTPs currently stored in the OTPStore.
///
/// This method is intended for unit testing purposes to access the size of
/// the OTPStore's internal store.
@visibleForTesting
int size() {
// ignore: invalid_use_of_visible_for_testing_member
return _otpStore.size();
}
}
10 changes: 9 additions & 1 deletion packages/at_secondary_server/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ dependencies:
at_server_spec: 3.0.15
at_persistence_spec: 2.0.14
at_persistence_secondary_server: 3.0.57
expire_cache: ^2.0.1
intl: ^0.18.1
json_annotation: ^4.8.0
version: 3.0.2
Expand All @@ -35,6 +34,15 @@ dependencies:
yaml: 3.1.2
logging: 1.2.0

dependency_overrides:
at_commons:
git:
url: https://github.com/atsign-foundation/at_libraries.git
path: packages/at_commons
ref: expire_otp_changes



dev_dependencies:
test: ^1.24.4
coverage: ^1.6.1
Expand Down
Loading

0 comments on commit af10334

Please sign in to comment.