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

1430: Extend card #1772

Merged
merged 10 commits into from
Nov 28, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,47 @@ import app.ehrenamtskarte.backend.cards.ValidityPeriodUtil.Companion.daysSinceEp
import app.ehrenamtskarte.backend.cards.ValidityPeriodUtil.Companion.isOnOrAfterToday
import app.ehrenamtskarte.backend.cards.ValidityPeriodUtil.Companion.isOnOrBeforeToday
import app.ehrenamtskarte.backend.cards.database.repos.CardRepository
import app.ehrenamtskarte.backend.userdata.database.UserEntitlementsEntity
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator
import org.jetbrains.exposed.sql.transactions.transaction
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import javax.crypto.spec.SecretKeySpec

val TIME_STEP: Duration = Duration.ofSeconds(30)
const val TOTP_LENGTH = 6

object CardVerifier {
public fun verifyStaticCard(project: String, cardHash: ByteArray, timezone: ZoneId): Boolean {
fun verifyStaticCard(project: String, cardHash: ByteArray, timezone: ZoneId): Boolean {
val card = transaction { CardRepository.findByHash(project, cardHash) } ?: return false
return !isExpired(card.expirationDay, timezone) && isYetValid(card.startDay, timezone) &&
!card.revoked
}

public fun verifyDynamicCard(project: String, cardHash: ByteArray, totp: Int, timezone: ZoneId): Boolean {
fun verifyDynamicCard(project: String, cardHash: ByteArray, totp: Int, timezone: ZoneId): Boolean {
val card = transaction { CardRepository.findByHash(project, cardHash) } ?: return false
return !isExpired(card.expirationDay, timezone) && isYetValid(card.startDay, timezone) &&
!card.revoked &&
isTotpValid(totp, card.totpSecret)
}

public fun isExpired(expirationDay: Long?, timezone: ZoneId): Boolean {
fun isExpired(expirationDay: Long?, timezone: ZoneId): Boolean {
return expirationDay != null && !isOnOrBeforeToday(daysSinceEpochToDate(expirationDay), timezone)
}

public fun isYetValid(startDay: Long?, timezone: ZoneId): Boolean {
fun isExtendable(project: String, cardHash: ByteArray): Boolean {
val card = transaction { CardRepository.findByHash(project, cardHash) } ?: return false
val expirationDay = card.expirationDay ?: return false
val entitlementId = card.entitlementId ?: return false

val userEntitlement = transaction { UserEntitlementsEntity.findById(entitlementId) } ?: return false

return LocalDate.ofEpochDay(expirationDay) < userEntitlement.endDate
}

private fun isYetValid(startDay: Long?, timezone: ZoneId): Boolean {
return startDay === null || isOnOrAfterToday(daysSinceEpochToDate(startDay), timezone)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,22 @@ class CardQueryService {
@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<GraphQLContext>()
val projectConfig = context.backendConfiguration.getProjectConfig(project)
val cardHash = Base64.getDecoder().decode(card.cardInfoHashBase64)
var verificationResult = false

if (card.codeType == CodeType.STATIC) {
verificationResult = card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone)
} else if (card.codeType == CodeType.DYNAMIC) {
verificationResult = card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone)
}
Matomo.trackVerification(context.backendConfiguration, projectConfig, context.request, dfe.field.name, cardHash, card.codeType, verificationResult)
return false
return verifyCardInProjectV2(project, card, dfe).valid
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
}

@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")
@GraphQLDescription("Returns whether there is a card in the given project with that hash registered for that this TOTP is currently valid, extendable and a timestamp of the last check")
fun verifyCardInProjectV2(project: String, card: CardVerificationModel, dfe: DataFetchingEnvironment): CardVerificationResultModel {
val context = dfe.getContext<GraphQLContext>()
val projectConfig = context.backendConfiguration.getProjectConfig(project)
val cardHash = Base64.getDecoder().decode(card.cardInfoHashBase64)
var verificationResult = CardVerificationResultModel(false)

if (card.codeType == CodeType.STATIC) {
verificationResult = CardVerificationResultModel(card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone))
} else if (card.codeType == CodeType.DYNAMIC) {
verificationResult = CardVerificationResultModel(card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone))
val isValid = when (card.codeType) {
CodeType.STATIC -> card.totp == null && CardVerifier.verifyStaticCard(project, cardHash, projectConfig.timezone)
CodeType.DYNAMIC -> card.totp != null && CardVerifier.verifyDynamicCard(project, cardHash, card.totp, projectConfig.timezone)
}

val verificationResult = CardVerificationResultModel(isValid, CardVerifier.isExtendable(project, cardHash))

Matomo.trackVerification(context.backendConfiguration, projectConfig, context.request, dfe.field.name, cardHash, card.codeType, verificationResult.valid)
return verificationResult
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import java.time.Instant

data class CardVerificationResultModel(
val valid: Boolean,
val extendable: Boolean = false,
seluianova marked this conversation as resolved.
Show resolved Hide resolved
val verificationTimeStamp: String = Instant.now().toString()
)
5 changes: 4 additions & 1 deletion frontend/assets/koblenz/l10n/override_de.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
"activateDescription": "Sie haben den KoblenzPass bereits beantragt und einen Aktivierungscode erhalten? Scannen Sie den Code hier ein.",
"activateTitle": "Pass aktivieren",
"applyDescription": "Sie haben noch keinen KoblenzPass? Hier können Sie Ihren KoblenzPass beantragen.",
"cardExpired": "Ihr Pass ist abgelaufen. Unter \"Weitere Aktionen\" können Sie einen Antrag auf Verlängerung stellen.",
"cardExpired": "Ihr Pass ist abgelaufen. Unter \"Weitere Aktionen\" können Sie Ihren Pass verlängern oder einen neuen Pass beantragen.",
"cardInvalid": "Ihr Pass ist ungültig. Er wurde entweder widerrufen oder auf einem anderen Gerät aktiviert.",
"cardNotYetValid": "Der Gültigkeitszeitraum Ihres Passes hat noch nicht begonnen.",
"checkFailed": "Ihr Pass konnte nicht auf seine Gültigkeit geprüft werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.",
"codeRevoked": "Dieser Pass konnte nicht aktiviert werden, da er widerrufen wurde.",
"extendCard": "Pass verlängern",
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
"extendCardNotificationTitle": "Hinweis zum Ablauf des Passes",
"extendCardNotificationDescription": "Ihr Pass läuft demnächst ab.\nVerlängern Sie ihn jetzt, um Ihre Vorteile weiterhin nutzen zu können.",
"moreActionsActivateDescription": "Ihr hinterlegter KoblenzPass bleibt erhalten. Sie können diesen manuell entfernen.",
"moreActionsActivateLimitDescription": "Um einen weiteren KoblenzPass hinzuzufügen, müssen Sie zuerst einen vorhandenen KoblenzPass löschen.",
"moreActionsActivateTitle": "Weiteren KoblenzPass hinzufügen",
Expand Down
3 changes: 3 additions & 0 deletions frontend/assets/l10n/app_de.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"codeVerificationFailedConnection": "Der eingescannte Code konnte nicht verifiziert werden. Bitte stellen Sie sicher, dass eine Internetverbindung besteht und prüfen Sie erneut.",
"compareWithID": "Gleichen Sie die angezeigten Daten mit einem amtlichen Lichtbildausweis ab.",
"comparedWithID": "Ich habe die Daten mit einem amtlichen Lichtbildausweis abgeglichen.",
"extendCard": "Karte verlängern",
"extendCardNotificationTitle": "Hinweis zum Ablauf der Karte",
"extendCardNotificationDescription": "Ihre Karte läuft demnächst ab.\nVerlängern Sie sie jetzt, um Ihre Vorteile weiterhin nutzen zu können.",
"flashOff": "Blitz aus",
"flashOn": "Blitz an",
"internetRequired": "Eine Internetverbindung wird benötigt.",
Expand Down
3 changes: 3 additions & 0 deletions frontend/assets/l10n/app_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@
"codeVerificationFailedConnection": "The scanned code could not be verified. Please make sure you have an internet connection and try again.",
"compareWithID": "Verify the displayed data against an official photo ID.",
"comparedWithID": "I verified the data against an official photo ID.",
"extendCard": "Extend card",
"extendCardNotificationTitle": "Card Expiry Notice",
"extendCardNotificationDescription": "Your card is about to expire.\nExtend it now to continue using your benefits.",
"flashOff": "Flash off",
"flashOn": "Flash on",
"internetRequired": "An internet connection is required.",
Expand Down
3 changes: 1 addition & 2 deletions frontend/build-configs/koblenz/publisherText.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export default `
Herausgegeben von:
export default `Herausgegeben von:

Stadt Koblenz

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
query CardVerificationByHash($project: String!, $card: CardVerificationModelInput!) {
verifyCardInProjectV2(project: $project, card: $card){
valid,
extendable,
verificationTimeStamp
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:ehrenamtskarte/configuration/configuration.dart';
import 'package:ehrenamtskarte/identification/card_detail_view/extend_card_notification.dart';
import 'package:ehrenamtskarte/identification/card_detail_view/more_actions_dialog.dart';
import 'package:ehrenamtskarte/identification/card_detail_view/self_verify_card.dart';
import 'package:ehrenamtskarte/identification/id_card/id_card_with_region_query.dart';
Expand Down Expand Up @@ -60,12 +61,13 @@ class _CardDetailViewState extends State<CardDetailView> {
Widget build(BuildContext context) {
final orientation = MediaQuery.of(context).orientation;

final cardInfo = widget.userCode.info;
final cardVerification = widget.userCode.cardVerification;

final paddedCard = Padding(
padding: const EdgeInsets.all(8),
child: IdCardWithRegionQuery(
cardInfo: widget.userCode.info,
isExpired: isCardExpired(widget.userCode.info),
isNotYetValid: isCardNotYetValid(widget.userCode.info)),
cardInfo: cardInfo, isExpired: isCardExpired(cardInfo), isNotYetValid: isCardNotYetValid(cardInfo)),
);
final qrCodeAndStatus = QrCodeAndStatus(
userCode: widget.userCode,
Expand All @@ -83,7 +85,16 @@ class _CardDetailViewState extends State<CardDetailView> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(child: paddedCard),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!isCardExpired(cardInfo) && isCardExtendable(cardInfo, cardVerification))
ExtendCardNotification(),
paddedCard,
],
)),
if (constraints.maxWidth > qrCodeMinWidth * 2)
Flexible(child: qrCodeAndStatus)
else
Expand All @@ -98,12 +109,19 @@ class _CardDetailViewState extends State<CardDetailView> {
)
: SingleChildScrollView(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(children: [paddedCard, const SizedBox(height: 16), qrCodeAndStatus]),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
if (!isCardExpired(cardInfo) && isCardExtendable(cardInfo, cardVerification))
ExtendCardNotification(),
paddedCard,
const SizedBox(height: 16),
qrCodeAndStatus,
],
),
),
);
));
}

void _onMoreActionsPressed(BuildContext context) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';

import 'package:ehrenamtskarte/build_config/build_config.dart' show buildConfig;
import 'package:ehrenamtskarte/configuration/definitions.dart';
import 'package:ehrenamtskarte/configuration/settings_model.dart';
import 'package:ehrenamtskarte/l10n/translations.g.dart';
import 'package:provider/provider.dart';
import 'package:tinycolor2/tinycolor2.dart';
import 'package:url_launcher/url_launcher_string.dart';

class ExtendCardNotification extends StatefulWidget {
@override
State<ExtendCardNotification> createState() => _ExtendCardNotificationState();
}

class _ExtendCardNotificationState extends State<ExtendCardNotification> {
bool _isVisible = true;

@override
Widget build(BuildContext context) {
if (!_isVisible) return Container();

final primaryColor = Theme.of(context).colorScheme.primary;
final backgroundColor =
Theme.of(context).brightness == Brightness.light ? primaryColor.tint(90) : primaryColor.shade(90);

return Padding(
padding: const EdgeInsets.all(8),
child: Card(
color: backgroundColor,
elevation: 1,
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildContent(context),
),
),
);
}

Widget _buildContent(BuildContext context) {
final t = context.t;

final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;

return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info, color: colorScheme.primary),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.identification.extendCardNotificationTitle,
style: textTheme.bodyLarge,
),
SizedBox(height: 8),
Text(
t.identification.extendCardNotificationDescription,
style: textTheme.bodyMedium,
),
SizedBox(height: 8),
FilledButton(
onPressed: () => _openApplication(),
child: Text(t.identification.extendCard.toUpperCase()),
),
],
),
),
SizedBox(width: 16),
GestureDetector(
onTap: () {
setState(() {
_isVisible = false;
});
},
child: Icon(Icons.close, size: 16),
),
],
);
}

Future<bool> _openApplication() {
// TODO add query params with card info
final isStagingEnabled = Provider.of<SettingsModel>(context, listen: false).enableStaging;
final applicationUrl = isStagingEnabled
? buildConfig.applicationUrl.staging
: isProduction()
? buildConfig.applicationUrl.production
: buildConfig.applicationUrl.local;
return launchUrlString(
applicationUrl,
mode: LaunchMode.externalApplication,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Future<void> selfVerifyCard(
..totpSecret = userCode.totpSecret
..cardVerification = (CardVerification()
..cardValid = cardVerification.valid
..cardExtendable = cardVerification.extendable
..verificationTimeStamp = secondsSinceEpoch(DateTime.parse(cardVerification.verificationTimeStamp))
..outOfSync = outOfSync));
}
29 changes: 21 additions & 8 deletions frontend/lib/identification/util/card_info_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ extension Hashing on CardInfo {
}

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());
final expirationDay = _getExpirationDayWithTolerance(cardInfo);
return expirationDay != null && expirationDay.isBefore(DateTime.now());
}

bool isCardExtendable(CardInfo cardInfo, CardVerification cardVerification) {
if (!cardVerification.cardExtendable) return false;

final expirationDay = _getExpirationDayWithTolerance(cardInfo);
if (expirationDay == null) return false;

return DateTime.now().isAfter(expirationDay.subtract(Duration(days: 90)));
}

bool cardWasVerifiedLately(CardVerification cardVerification) {
Expand All @@ -49,3 +52,13 @@ bool isCardNotYetValid(CardInfo cardInfo) {
.add(Duration(days: startingDay))
.isAfter(DateTime.now().toUtc());
}

DateTime? _getExpirationDayWithTolerance(CardInfo cardInfo) {
final expirationDay = cardInfo.hasExpirationDay() ? cardInfo.expirationDay : null;
if (expirationDay == null) return null;

// Add 24 hours to be valid on the expiration day and 12h to cover UTC+12
const toleranceInHours = 36;
return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
.add(Duration(days: expirationDay, hours: toleranceInHours));
}
22 changes: 22 additions & 0 deletions frontend/lib/themes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ ThemeData get lightTheme {
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(side: MaterialStatePropertyAll(BorderSide(color: primaryColor, width: 1))),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(primaryColor),
elevation: MaterialStatePropertyAll(2),
shape: MaterialStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
),
checkboxTheme: CheckboxThemeData(
checkColor: MaterialStatePropertyAll(textColor),
fillColor: MaterialStatePropertyAll(primaryColor),
Expand Down Expand Up @@ -120,6 +131,17 @@ ThemeData get darkTheme {
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(side: MaterialStatePropertyAll(BorderSide(color: primaryColor, width: 1))),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(primaryColor),
elevation: MaterialStatePropertyAll(2),
shape: MaterialStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
),
checkboxTheme: CheckboxThemeData(
checkColor: const MaterialStatePropertyAll(Colors.white),
fillColor: MaterialStatePropertyAll(primaryColor),
Expand Down
Loading