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

feat: Implement OTP Store and enable client to set OTP expiry #1598

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
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