diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardQueryService.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardQueryService.kt index 311a62e80..707f8f628 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardQueryService.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/CardQueryService.kt @@ -5,13 +5,15 @@ import app.ehrenamtskarte.backend.exception.service.ProjectNotFoundException import app.ehrenamtskarte.backend.verification.database.CodeType import app.ehrenamtskarte.backend.verification.service.CardVerifier import app.ehrenamtskarte.backend.verification.webservice.schema.types.CardVerificationModel +import app.ehrenamtskarte.backend.verification.webservice.schema.types.CardVerificationResultModel import com.expediagroup.graphql.generator.annotations.GraphQLDescription import graphql.schema.DataFetchingEnvironment import java.util.Base64 @Suppress("unused") class CardQueryService { - @GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid") + @Deprecated("Deprecated since May 2023 in favor of CardVerificationResultModel that return a current timestamp", ReplaceWith("verifyCardInProjectV2")) + @GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check") fun verifyCardInProject(project: String, card: CardVerificationModel, dfe: DataFetchingEnvironment): Boolean { val context = dfe.getContext() val projectConfig = context.backendConfiguration.projects.find { it.id == project } ?: throw ProjectNotFoundException(project) @@ -24,4 +26,18 @@ class CardQueryService { } return false } + + @GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check") + fun verifyCardInProjectV2(project: String, card: CardVerificationModel, dfe: DataFetchingEnvironment): CardVerificationResultModel { + val context = dfe.getContext() + val projectConfig = context.backendConfiguration.projects.find { it.id == project } ?: throw ProjectNotFoundException(project) + val cardHash = Base64.getDecoder().decode(card.cardInfoHashBase64) + + if (card.codeType == CodeType.STATIC) { + return CardVerificationResultModel(card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone)) + } else if (card.codeType == CodeType.DYNAMIC) { + return CardVerificationResultModel(card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone)) + } + return CardVerificationResultModel(false) + } } diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardActivationResultModel.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardActivationResultModel.kt index fef1462f2..a0be1a315 100644 --- a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardActivationResultModel.kt +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardActivationResultModel.kt @@ -1,5 +1,7 @@ package app.ehrenamtskarte.backend.verification.webservice.schema.types +import java.time.Instant + @Suppress("ktlint:enum-entry-name-case") enum class ActivationState { success, @@ -9,5 +11,6 @@ enum class ActivationState { data class CardActivationResultModel( val activationState: ActivationState, - val totpSecret: String? = null + val totpSecret: String? = null, + val activationTimeStamp: String = Instant.now().toString() ) diff --git a/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardVerificationResultModel.kt b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardVerificationResultModel.kt new file mode 100644 index 000000000..ba6c03ab0 --- /dev/null +++ b/backend/src/main/kotlin/app/ehrenamtskarte/backend/verification/webservice/schema/types/CardVerificationResultModel.kt @@ -0,0 +1,8 @@ +package app.ehrenamtskarte.backend.verification.webservice.schema.types + +import java.time.Instant + +data class CardVerificationResultModel( + val valid: Boolean, + val verificationTimeStamp: String = Instant.now().toString() +) diff --git a/frontend/build-configs/bayern/localization.ts b/frontend/build-configs/bayern/localization.ts index d53cd11c8..5d0d91f22 100644 --- a/frontend/build-configs/bayern/localization.ts +++ b/frontend/build-configs/bayern/localization.ts @@ -22,7 +22,7 @@ const localization: LocalizationType = { positiveVerificationDialogTitle: "Karte ist gültig", }, moreActions: { - applyForAnotherCardTitle: "Weitere Ehrenamtskarte beantragen", + applyForAnotherCardTitle: "Ehrenamtskarte beantragen oder verlängern", applyForAnotherCardDescription: "Ihre hinterlegte Karte bleibt erhalten.", activateAnotherCardTitle: "Anderen Aktivierungscode einscannen", activateAnotherCardDescription: "Dadurch wird die hinterlegte Karte vom Gerät gelöscht.", diff --git a/frontend/build-configs/nuernberg/localization.ts b/frontend/build-configs/nuernberg/localization.ts index c175f3dc0..a04732caa 100644 --- a/frontend/build-configs/nuernberg/localization.ts +++ b/frontend/build-configs/nuernberg/localization.ts @@ -22,7 +22,7 @@ const localization: LocalizationType = { positiveVerificationDialogTitle: "Pass ist gültig", }, moreActions: { - applyForAnotherCardTitle: "Weiteren Nürnberg-Pass beantragen", + applyForAnotherCardTitle: "Nürnberg-Pass beantragen oder verlängern", applyForAnotherCardDescription: "Ihr hinterlegter Pass bleibt erhalten.", activateAnotherCardTitle: "Anderen Aktivierungscode einscannen", activateAnotherCardDescription: "Dadurch wird der hinterlegte Pass vom Gerät gelöscht.", diff --git a/frontend/graphql_queries/verification/card_activation.graphql b/frontend/graphql_queries/verification/card_activation.graphql index db679c7f3..2163e1a4d 100644 --- a/frontend/graphql_queries/verification/card_activation.graphql +++ b/frontend/graphql_queries/verification/card_activation.graphql @@ -1,6 +1,7 @@ mutation ActivateCard($project: String!, $cardInfoHashBase64: String!, $activationSecretBase64: String!, $overwrite: Boolean!) { activateCard(project: $project, cardInfoHashBase64: $cardInfoHashBase64, activationSecretBase64: $activationSecretBase64, overwrite: $overwrite) { activationState, - totpSecret + totpSecret, + activationTimeStamp } } diff --git a/frontend/graphql_queries/verification/card_verification_by_hash.graphql b/frontend/graphql_queries/verification/card_verification_by_hash.graphql index 642cc74f2..24d8ba427 100644 --- a/frontend/graphql_queries/verification/card_verification_by_hash.graphql +++ b/frontend/graphql_queries/verification/card_verification_by_hash.graphql @@ -1,3 +1,6 @@ query CardVerificationByHash($project: String!, $card: CardVerificationModelInput!) { - cardValid: verifyCardInProject(project: $project, card: $card) + verifyCardInProjectV2(project: $project, card: $card){ + valid, + verificationTimeStamp + } } diff --git a/frontend/lib/about/dev_settings_view.dart b/frontend/lib/about/dev_settings_view.dart index 058637d30..47397a866 100644 --- a/frontend/lib/about/dev_settings_view.dart +++ b/frontend/lib/about/dev_settings_view.dart @@ -16,6 +16,7 @@ import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; import 'package:ehrenamtskarte/intro_slides/intro_screen.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:ehrenamtskarte/routing.dart'; +import 'package:ehrenamtskarte/util/date_utils.dart'; import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:provider/provider.dart'; @@ -83,6 +84,10 @@ class DevSettingsView extends StatelessWidget { title: const Text('Show Intro Slides'), onTap: () => _showIntroSlides(context), ), + ListTile( + title: const Text('Set expired last card verification'), + onTap: () => _setExpiredLastVerification(context), + ), ListTile( title: const Text('Log sample exception'), onTap: () => log("Sample exception.", error: Exception("Sample exception...")), @@ -234,4 +239,20 @@ class DevSettingsView extends StatelessWidget { ), ); } + + // This is used to check the invalidation of a card because the verification with the backend couldn't be done lately (1 week plus UTC tolerance) + void _setExpiredLastVerification(BuildContext context) { + final provider = Provider.of(context, listen: false); + final DynamicUserCode userCode = provider.userCode!; + final CardVerification cardVerification = CardVerification( + verificationTimeStamp: + secondsSinceEpoch(DateTime.now().toUtc().subtract(Duration(seconds: cardValidationExpireSeconds + 3600))), + cardValid: true); + provider.setCode(DynamicUserCode( + info: userCode.info, + ecSignature: userCode.ecSignature, + pepper: userCode.pepper, + totpSecret: userCode.totpSecret, + cardVerification: cardVerification)); + } } diff --git a/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart b/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart index ba07f5f8b..30e5d82ee 100644 --- a/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart +++ b/frontend/lib/identification/activation_workflow/activation_code_scanner_page.dart @@ -15,6 +15,7 @@ import 'package:ehrenamtskarte/identification/user_code_model.dart'; import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; import 'package:ehrenamtskarte/identification/verification_workflow/verification_qr_code_processor.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; +import 'package:ehrenamtskarte/util/date_utils.dart'; import 'package:ehrenamtskarte/widgets/app_bars.dart'; import 'package:flutter/widgets.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; @@ -97,12 +98,14 @@ class ActivationCodeScannerPage extends StatelessWidget { throw const ActivationInvalidTotpSecretException(); } final totpSecret = const Base64Decoder().convert(activationResult.totpSecret!); - final userCode = DynamicUserCode( - info: activationCode.info, - pepper: activationCode.pepper, - totpSecret: totpSecret, - ); - provider.setCode(userCode); + + provider.setCode(DynamicUserCode( + info: activationCode.info, + pepper: activationCode.pepper, + totpSecret: totpSecret, + cardVerification: CardVerification( + cardValid: true, + verificationTimeStamp: secondsSinceEpoch(DateTime.parse(activationResult.activationTimeStamp))))); break; case ActivationState.failed: await QrParsingErrorDialog.showErrorDialog( diff --git a/frontend/lib/identification/card_detail_view/card_detail_view.dart b/frontend/lib/identification/card_detail_view/card_detail_view.dart index 2677f3102..9e9371d29 100644 --- a/frontend/lib/identification/card_detail_view/card_detail_view.dart +++ b/frontend/lib/identification/card_detail_view/card_detail_view.dart @@ -3,6 +3,7 @@ import 'package:ehrenamtskarte/graphql/graphql_api.dart'; import 'package:ehrenamtskarte/identification/card_detail_view/more_actions_dialog.dart'; import 'package:ehrenamtskarte/identification/card_detail_view/verification_code_view.dart'; import 'package:ehrenamtskarte/identification/id_card/id_card.dart'; +import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:flutter/material.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; @@ -45,11 +46,14 @@ class CardDetailView extends StatelessWidget { final paddedCard = Padding( padding: const EdgeInsets.all(8), child: IdCard( - cardInfo: userCode.info, - region: region != null ? Region(region.prefix, region.name) : null, - ), + cardInfo: userCode.info, + region: region != null ? Region(region.prefix, region.name) : null, + isExpired: isCardExpired(userCode.info)), ); - final richQrCode = RichQrCode(userCode: userCode, onMoreActionsPressed: () => _onMoreActionsPressed(context)); + final richQrCode = RichQrCode( + userCode: userCode, + onMoreActionsPressed: () => _onMoreActionsPressed(context), + isExpired: isCardExpired(userCode.info)); return orientation == Orientation.landscape ? SafeArea( @@ -102,11 +106,20 @@ class RichQrCode extends StatelessWidget { final VoidCallback onMoreActionsPressed; final DynamicUserCode userCode; final bool compact; + final bool isExpired; - const RichQrCode({super.key, required this.onMoreActionsPressed, required this.userCode, this.compact = false}); + const RichQrCode( + {super.key, + required this.onMoreActionsPressed, + required this.userCode, + this.compact = false, + required this.isExpired}); @override Widget build(BuildContext context) { + final wasCardVerifiedLately = cardWasVerifiedLately(userCode.cardVerification); + final isCardInvalid = !userCode.cardVerification.cardValid; + return Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Column( @@ -115,13 +128,15 @@ class RichQrCode extends StatelessWidget { Container( padding: const EdgeInsets.only(bottom: 4), constraints: const BoxConstraints(maxWidth: 300), - child: const Text( - "Mit diesem QR-Code können Sie sich" - " bei Akzeptanzstellen ausweisen:", + child: Text( + getCardInfoText(wasCardVerifiedLately, isCardInvalid, context), textAlign: TextAlign.center, ), ), - Flexible(child: VerificationCodeView(userCode: userCode)), + Flexible( + child: (isExpired || isCardInvalid) + ? Container() + : VerificationCodeView(userCode: userCode, isCardVerificationExpired: !wasCardVerifiedLately)), Container( alignment: Alignment.center, child: TextButton( @@ -136,4 +151,18 @@ class RichQrCode extends StatelessWidget { ), ); } + + String getCardInfoText(bool wasCardVerifiedLately, bool isCardInvalid, BuildContext context) { + if (isExpired) { + return "Ihre Karte ist abgelaufen.\nUnter \"Weitere Aktionen\" können Sie einen Antrag auf Verlängerung stellen."; + } + if (isCardInvalid) { + return 'Ihre Karte ist ungültig.\nSie wurde entweder widerrufen oder auf einem anderen Gerät aktiviert.'; + } + if (!wasCardVerifiedLately) { + return 'Ihre Karte konnte nicht auf ihre Gültigkeit geprüft werden. Bitte stellen Sie sicher, dass eine Verbindung mit dem Internet besteht und prüfen Sie erneut.'; + } + + return 'Mit diesem QR-Code können Sie sich bei Akzeptanzstellen ausweisen:'; + } } diff --git a/frontend/lib/identification/card_detail_view/more_actions_dialog.dart b/frontend/lib/identification/card_detail_view/more_actions_dialog.dart index 87e8c234c..0416d319f 100644 --- a/frontend/lib/identification/card_detail_view/more_actions_dialog.dart +++ b/frontend/lib/identification/card_detail_view/more_actions_dialog.dart @@ -19,37 +19,39 @@ class MoreActionsDialog extends StatelessWidget { return AlertDialog( contentPadding: const EdgeInsets.only(top: 12), title: const Text("Weitere Aktionen"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text(localization.applyForAnotherCardTitle), - subtitle: Text(localization.applyForAnotherCardDescription), - leading: const Icon(Icons.assignment, size: 36), - onTap: () { - Navigator.pop(context); - startApplication(); - }, - ), - ListTile( - title: Text(localization.activateAnotherCardTitle), - subtitle: Text(localization.activateAnotherCardDescription), - leading: const Icon(Icons.add_card, size: 36), - onTap: () { - Navigator.pop(context); - startActivation(); - }, - ), - ListTile( - title: Text(localization.verifyTitle), - subtitle: Text(localization.verifyDescription), - leading: const Icon(Icons.verified, size: 36), - onTap: () { - Navigator.pop(context); - startVerification(); - }, - ), - ], + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(localization.applyForAnotherCardTitle), + subtitle: Text(localization.applyForAnotherCardDescription), + leading: const Icon(Icons.assignment, size: 36), + onTap: () { + Navigator.pop(context); + startApplication(); + }, + ), + ListTile( + title: Text(localization.activateAnotherCardTitle), + subtitle: Text(localization.activateAnotherCardDescription), + leading: const Icon(Icons.add_card, size: 36), + onTap: () { + Navigator.pop(context); + startActivation(); + }, + ), + ListTile( + title: Text(localization.verifyTitle), + subtitle: Text(localization.verifyDescription), + leading: const Icon(Icons.verified, size: 36), + onTap: () { + Navigator.pop(context); + startVerification(); + }, + ), + ], + ), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("Abbrechen"))], ); diff --git a/frontend/lib/identification/card_detail_view/verification_code_view.dart b/frontend/lib/identification/card_detail_view/verification_code_view.dart index 234cc5dcd..b6c07ca45 100644 --- a/frontend/lib/identification/card_detail_view/verification_code_view.dart +++ b/frontend/lib/identification/card_detail_view/verification_code_view.dart @@ -1,20 +1,27 @@ import 'dart:math'; +import 'package:ehrenamtskarte/configuration/configuration.dart'; import 'package:ehrenamtskarte/identification/card_detail_view/animated_progressbar.dart'; import 'package:ehrenamtskarte/identification/otp_generator.dart'; import 'package:ehrenamtskarte/identification/qr_content_parser.dart'; import 'package:ehrenamtskarte/identification/user_code_model.dart'; +import 'package:ehrenamtskarte/identification/verification_workflow/query_server_verification.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; +import 'package:ehrenamtskarte/util/date_utils.dart'; import 'package:ehrenamtskarte/widgets/small_button_spinner.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:graphql_flutter/graphql_flutter.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart' as qr show QrImage, QrCode, QrVersions, QrErrorCorrectLevel; class VerificationCodeView extends StatefulWidget { final DynamicUserCode userCode; final OTPGenerator _otpGenerator; + final bool isCardVerificationExpired; - VerificationCodeView({super.key, required this.userCode}) : _otpGenerator = OTPGenerator(userCode.totpSecret); + VerificationCodeView({super.key, required this.userCode, required this.isCardVerificationExpired}) + : _otpGenerator = OTPGenerator(userCode.totpSecret); @override VerificationCodeViewState createState() => VerificationCodeViewState(); @@ -27,6 +34,11 @@ class VerificationCodeViewState extends State { void initState() { super.initState(); _otpCode = widget._otpGenerator.generateOTP(_resetQrCode); + // On every app start when this widget will be initialized, we verify with the backend if the card is valid, in order to detect if one of the following events happened: + // - the card was activated on another device + // - the card was revoked + // - the card expired (on backend's system time) + SchedulerBinding.instance.addPostFrameCallback((_) => verifyCard(_otpCode, widget.userCode, context)); } _resetQrCode() { @@ -39,11 +51,11 @@ class VerificationCodeViewState extends State { Widget build(BuildContext context) { final otpCode = _otpCode; final userCode = widget.userCode; + final isCardVerificationExpired = widget.isCardVerificationExpired; if (otpCode == null) { return const SmallButtonSpinner(); } - final time = DateTime.now().millisecondsSinceEpoch; final animationDuration = otpCode.validUntilMilliSeconds - time; return LayoutBuilder( @@ -57,32 +69,65 @@ class VerificationCodeViewState extends State { ); qrCode.make(); - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), - child: Material( - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Stack( - children: [ - Padding( - padding: EdgeInsets.all(padding), - child: qr.QrImage.withQr( - qr: qrCode, - version: qr.QrVersions.auto, - foregroundColor: Theme.of(context).textTheme.bodyMedium?.color, - gapless: false, + return isCardVerificationExpired + ? TextButton.icon( + icon: const Icon(Icons.refresh), + onPressed: () { + verifyCard(otpCode, userCode, context); + }, + label: Text("Erneut prüfen"), + ) + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), + child: Material( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Stack( + children: [ + Padding( + padding: EdgeInsets.all(padding), + child: qr.QrImage.withQr( + qr: qrCode, + version: qr.QrVersions.auto, + foregroundColor: Theme.of(context).textTheme.bodyMedium?.color, + gapless: false, + ), + ), + Positioned.fill( + child: AnimatedProgressbar(initialProgress: Duration(milliseconds: animationDuration)), + ), + ], ), ), - Positioned.fill( - child: AnimatedProgressbar(initialProgress: Duration(milliseconds: animationDuration)), - ), - ], - ), - ), - ); + ); }, ); }, ); } + + Future verifyCard(OTPCode? otpCode, DynamicUserCode userCode, BuildContext context) async { + if (otpCode == null) { + throw Exception("otp code is not available"); + } + final projectId = Configuration.of(context).projectId; + final client = GraphQLProvider.of(context).value; + final DynamicVerificationCode qrCode = DynamicVerificationCode( + info: userCode.info, + pepper: userCode.pepper, + otp: otpCode.code, + ); + + final cardVerification = await queryDynamicServerVerification(client, projectId, qrCode); + final provider = Provider.of(context, listen: false); + + provider.setCode(DynamicUserCode( + info: userCode.info, + ecSignature: userCode.ecSignature, + pepper: userCode.pepper, + totpSecret: userCode.totpSecret, + cardVerification: CardVerification( + cardValid: cardVerification.valid, + verificationTimeStamp: secondsSinceEpoch(DateTime.parse(cardVerification.verificationTimeStamp))))); + } } diff --git a/frontend/lib/identification/id_card/card_content.dart b/frontend/lib/identification/id_card/card_content.dart index f2b96e4c0..601904a4d 100644 --- a/frontend/lib/identification/id_card/card_content.dart +++ b/frontend/lib/identification/id_card/card_content.dart @@ -1,4 +1,4 @@ -import 'package:ehrenamtskarte/build_config/build_config.dart'; +import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig; import 'package:ehrenamtskarte/identification/id_card/card_header_logo.dart'; import 'package:ehrenamtskarte/identification/id_card/id_card.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; @@ -40,8 +40,9 @@ PaddingStyle paddingHeader = PaddingStyle( class CardContent extends StatelessWidget { final CardInfo cardInfo; final Region? region; + final bool isExpired; - const CardContent({super.key, required this.cardInfo, this.region}); + const CardContent({super.key, required this.cardInfo, this.region, required this.isExpired}); String get _formattedExpirationDate { final expirationDay = cardInfo.hasExpirationDay() ? cardInfo.expirationDay : null; @@ -182,7 +183,9 @@ class CardContent extends StatelessWidget { maxLines: 1, text: TextSpan( text: "Gültig bis: ", - style: TextStyle(fontSize: 14 * scaleFactor, color: textColor), + style: TextStyle( + fontSize: 14 * scaleFactor, + color: isExpired ? Theme.of(context).colorScheme.error : textColor), children: [TextSpan(text: _formattedExpirationDate)], ), ), diff --git a/frontend/lib/identification/id_card/id_card.dart b/frontend/lib/identification/id_card/id_card.dart index fd8ae9ccc..652ac5a3c 100644 --- a/frontend/lib/identification/id_card/id_card.dart +++ b/frontend/lib/identification/id_card/id_card.dart @@ -20,8 +20,9 @@ class Region with EquatableMixin { class IdCard extends StatelessWidget { final CardInfo cardInfo; final Region? region; + final bool isExpired; - const IdCard({super.key, required this.cardInfo, required this.region}); + const IdCard({super.key, required this.cardInfo, required this.region, required this.isExpired}); @override Widget build(BuildContext context) { @@ -38,10 +39,7 @@ class IdCard extends StatelessWidget { child: MediaQuery( // Ignore text scale factor to enforce the same layout on all devices. data: mediaQueryData.copyWith(textScaleFactor: 1), - child: CardContent( - cardInfo: cardInfo, - region: region, - ), + child: CardContent(cardInfo: cardInfo, region: region, isExpired: isExpired), ), ), ), diff --git a/frontend/lib/identification/util/card_info_utils.dart b/frontend/lib/identification/util/card_info_utils.dart index 8403451c5..23409a541 100644 --- a/frontend/lib/identification/util/card_info_utils.dart +++ b/frontend/lib/identification/util/card_info_utils.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:ehrenamtskarte/identification/util/canonical_json.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; +import 'package:ehrenamtskarte/util/date_utils.dart'; import 'package:ehrenamtskarte/util/json_canonicalizer.dart'; extension Hashing on CardInfo { @@ -19,3 +20,23 @@ extension Hashing on CardInfo { return utf8.encode(canonicalizedJsonString); } } + +bool isCardExpired(CardInfo cardInfo) { + final expirationDay = cardInfo.hasExpirationDay() ? cardInfo.expirationDay : null; + // Add 24 hours to be valid on the expiration day and 12h to cover UTC+12 + final int toleranceInHours = 36; + return expirationDay == null + ? false + : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true) + .add(Duration(days: expirationDay, hours: toleranceInHours)) + .isBefore(DateTime.now()); +} + +bool cardWasVerifiedLately(CardVerification cardVerification) { + final lastVerificationTimestamp = + cardVerification.hasVerificationTimeStamp() ? cardVerification.verificationTimeStamp : null; + return lastVerificationTimestamp == null + ? false + : DateTime.now().toUtc().isBefore(DateTime.fromMillisecondsSinceEpoch(0) + .add(Duration(seconds: lastVerificationTimestamp.toInt() + cardValidationExpireSeconds))); +} diff --git a/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart b/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart index a508ca141..84a11ed01 100644 --- a/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart +++ b/frontend/lib/identification/verification_workflow/dialogs/positive_verification_result_dialog.dart @@ -63,9 +63,10 @@ class PositiveVerificationResultDialogState extends State[ Flexible( child: IdCard( - cardInfo: widget.cardInfo, - region: region != null ? Region(region.prefix, region.name) : null, - ), + cardInfo: widget.cardInfo, + region: region != null ? Region(region.prefix, region.name) : null, + // We trust the backend to have checked for expiration. + isExpired: false), ), if (widget.isStaticVerificationCode) Flexible( diff --git a/frontend/lib/identification/verification_workflow/query_server_verification.dart b/frontend/lib/identification/verification_workflow/query_server_verification.dart index 62e2a9305..849fe06ca 100644 --- a/frontend/lib/identification/verification_workflow/query_server_verification.dart +++ b/frontend/lib/identification/verification_workflow/query_server_verification.dart @@ -3,7 +3,7 @@ import 'package:ehrenamtskarte/identification/util/card_info_utils.dart'; import 'package:ehrenamtskarte/proto/card.pb.dart'; import 'package:graphql_flutter/graphql_flutter.dart'; -Future queryDynamicServerVerification( +Future queryDynamicServerVerification( GraphQLClient client, String projectId, DynamicVerificationCode verificationCode, @@ -18,7 +18,7 @@ Future queryDynamicServerVerification( ); } -Future queryStaticServerVerification( +Future queryStaticServerVerification( GraphQLClient client, String projectId, StaticVerificationCode verificationCode, @@ -33,7 +33,7 @@ Future queryStaticServerVerification( ); } -Future _queryServerVerification( +Future _queryServerVerification( GraphQLClient client, String projectId, String verificationHash, @@ -65,11 +65,11 @@ Future _queryServerVerification( final data = queryResult.data; if (data == null) { - return false; + return CardVerificationByHash$Query$CardVerificationResultModel(); } final parsedResult = byCardDetailsHash.parse(data); - return parsedResult.cardValid; + return parsedResult.verifyCardInProjectV2; } on Object catch (e) { throw ServerVerificationException(e); } diff --git a/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart b/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart index 27bfb3a82..c09fe2c83 100644 --- a/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart +++ b/frontend/lib/identification/verification_workflow/verification_qr_code_processor.dart @@ -31,7 +31,7 @@ Future verifyDynamicVerificationCode( ) async { assertConsistentCardInfo(code.info); _assertConsistentDynamicVerificationCode(code); - if (!(await queryDynamicServerVerification(client, projectId, code))) { + if (!(await queryDynamicServerVerification(client, projectId, code)).valid) { return null; } return code.info; @@ -44,7 +44,7 @@ Future verifyStaticVerificationCode( ) async { assertConsistentCardInfo(code.info); _assertConsistentStaticVerificationCode(code); - if (!(await queryStaticServerVerification(client, projectId, code))) { + if (!(await queryStaticServerVerification(client, projectId, code)).valid) { return null; } return code.info; diff --git a/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart b/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart index 3c9de9a98..e31336d2a 100644 --- a/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart +++ b/frontend/lib/identification/verification_workflow/verification_qr_scanner_page.dart @@ -140,9 +140,6 @@ class VerificationQrScannerPage extends StatelessWidget { Future _onSuccess(BuildContext context, CardInfo cardInfo, bool isStaticVerificationCode) async { await PositiveVerificationResultDialog.show( - context: context, - cardInfo: cardInfo, - isStaticVerificationCode: isStaticVerificationCode, - ); + context: context, cardInfo: cardInfo, isStaticVerificationCode: isStaticVerificationCode); } } diff --git a/frontend/lib/themes.dart b/frontend/lib/themes.dart index 3a1081c18..71cebd3eb 100644 --- a/frontend/lib/themes.dart +++ b/frontend/lib/themes.dart @@ -12,6 +12,7 @@ ThemeData get lightTheme { secondary: primaryColor, background: Colors.white, surfaceVariant: const Color(0xffefefef), + error: const Color(0xffcc0000), ), textTheme: defaultTypography.copyWith( headlineMedium: defaultTypography.headlineMedium?.apply(color: Colors.black87), @@ -54,6 +55,7 @@ ThemeData get darkTheme { secondary: primaryColor, background: const Color(0xff121212), surfaceVariant: const Color(0xff262626), + error: const Color(0xff8b0000), ), textTheme: defaultTypography.copyWith( headlineMedium: defaultTypography.headlineMedium?.apply(color: Colors.white), diff --git a/frontend/lib/util/date_utils.dart b/frontend/lib/util/date_utils.dart new file mode 100644 index 000000000..d0a10ffc9 --- /dev/null +++ b/frontend/lib/util/date_utils.dart @@ -0,0 +1,8 @@ +import 'package:fixnum/fixnum.dart'; + +Int64 secondsSinceEpoch(DateTime date) { + return Int64(date.difference(DateTime.utc(1970)).inSeconds); +} + +// 7days x 24h + 1day for expiration day and 12h for UTC+12 = 192h * 3600 +int cardValidationExpireSeconds = 691200; diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 00adc488a..e1c293108 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -280,7 +280,7 @@ packages: source: hosted version: "6.1.4" fixnum: - dependency: transitive + dependency: "direct main" description: name: fixnum sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 33288b192..1ecb137b4 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: permission_handler: ^10.2.0 package_info_plus: ^3.0.3 # for about dialog mobile_scanner: 3.1.1 # Pinning this version because of https://github.com/juliansteenbakker/mobile_scanner/issues/582 + fixnum: ^1.1.0 flutter_secure_storage: ^8.0.0 infinite_scroll_pagination: ^3.2.0 geolocator: ^9.0.2 diff --git a/specs/backend-api.graphql b/specs/backend-api.graphql index db70cb66c..3b17c8a47 100644 --- a/specs/backend-api.graphql +++ b/specs/backend-api.graphql @@ -73,9 +73,15 @@ type ApplicationView { type CardActivationResultModel { activationState: ActivationState! + activationTimeStamp: String! totpSecret: String } +type CardVerificationResultModel { + valid: Boolean! + verificationTimeStamp: String! +} + type Category { id: Int! name: String! @@ -174,8 +180,10 @@ type Query { searchAcceptingStores(params: SearchParamsInput!): [AcceptingStore!]! @deprecated(reason : "Deprecated in favor of project specific query, replace with searchAcceptingStoresInProject") "Search for accepting stores in the given project using searchText and categoryIds." searchAcceptingStoresInProject(params: SearchParamsInput!, project: String!): [AcceptingStore!]! - "Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid" - verifyCardInProject(card: CardVerificationModelInput!, project: String!): Boolean! + "Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check" + verifyCardInProject(card: CardVerificationModelInput!, project: String!): Boolean! @deprecated(reason : "Deprecated since May 2023 in favor of CardVerificationResultModel that return a current timestamp, replace with verifyCardInProjectV2") + "Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid and a timestamp of the last check" + verifyCardInProjectV2(card: CardVerificationModelInput!, project: String!): CardVerificationResultModel! "Returns the requesting administrator as retrieved from his JWT token." whoAmI(project: String!): Administrator! } diff --git a/specs/card.proto b/specs/card.proto index a88ceead1..fbcb2de95 100644 --- a/specs/card.proto +++ b/specs/card.proto @@ -1,5 +1,6 @@ syntax = "proto3"; + message RegionExtension { // Using int32 instead of uint32, even though we do not expect negative values. // The reason is that int32 are better supported in GraphQL and SQL. @@ -58,6 +59,16 @@ message CardInfo { optional CardExtensions extensions = 3; } +message CardVerification { + optional bool cardValid = 1; + + // Verification timestamp in seconds (calculated from 1970-01-01 UTC). + // Using uint64 should be good for 584,942,417,355 years after 1970. + // This timestamp is used to invalidate the card after a certain time period not verified with the backend (in this case 7 days). + // This timestamp will be updated after card activation and card validation on app start. + optional uint64 verificationTimeStamp = 2; +} + message QrCode { oneof qr_code { DynamicActivationCode dynamic_activation_code = 1; @@ -78,6 +89,8 @@ message DynamicUserCode { bytes pepper = 2; bytes totp_secret = 3; bytes ec_signature = 4; + CardVerification card_verification = 5; + } message DynamicVerificationCode {