Kopf-Sammelabrechnung
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
|
||||
|
||||
|
||||
' Requires NuGet:
|
||||
' - Confluent.Kafka
|
||||
' - Newtonsoft.Json
|
||||
' Target framework: .NET Framework 4.8 oder .NET 6/8 (passt beides)
|
||||
|
||||
Imports System.Threading
|
||||
Imports System.Threading.Tasks
|
||||
Imports Confluent.Kafka
|
||||
Imports Newtonsoft.Json
|
||||
|
||||
Namespace Verag.Udm
|
||||
|
||||
''' <summary>
|
||||
''' UDM-Record inkl. Beispielbefüllung und Kafka-Producer.
|
||||
''' Datenschema gemäß bereitgestellter JSON-Struktur. :contentReference[oaicite:1]{index=1}
|
||||
''' </summary>
|
||||
Public Class cATEZ_Greenpulse_KafkaDecs
|
||||
|
||||
'========================
|
||||
'== Kafka: Konfiguration (Klassenebene)
|
||||
'========================
|
||||
Public Shared BootstrapServers As String = "192.168.85.250:8888" 'http://192.168.85.250:8888
|
||||
Public Shared TopicName As String = "greenpulse.declarationdata.v1"
|
||||
' Falls SASL/TLS benötigt:
|
||||
Public Shared UseSasl As Boolean = False
|
||||
Public Shared SaslUsername As String = ""
|
||||
Public Shared SaslPassword As String = ""
|
||||
Public Shared SecurityProtocolSetting As SecurityProtocol = SecurityProtocol.Plaintext
|
||||
Public Shared SaslMechanismSetting As SaslMechanism = SaslMechanism.Plain
|
||||
|
||||
'========================
|
||||
'== Datenobjekte lt. UDM-Schema
|
||||
'========================
|
||||
|
||||
<JsonProperty("declaration")>
|
||||
Public Property Declaration As DeclarationNode
|
||||
|
||||
<JsonProperty("parties")>
|
||||
Public Property Parties As PartiesNode
|
||||
|
||||
<JsonProperty("commercial")>
|
||||
Public Property Commercial As CommercialNode
|
||||
|
||||
<JsonProperty("exporterDetails")>
|
||||
Public Property ExporterDetails As ExporterDetailsNode
|
||||
|
||||
<JsonProperty("importerDetails")>
|
||||
Public Property ImporterDetails As ImporterDetailsNode
|
||||
|
||||
'--- declaration ---
|
||||
Public Class DeclarationNode
|
||||
<JsonProperty("declarationsourceId")>
|
||||
Public Property DeclarationSourceId As String
|
||||
|
||||
<JsonProperty("declarationNo")>
|
||||
Public Property DeclarationNo As String
|
||||
|
||||
<JsonProperty("declarationDate")>
|
||||
Public Property DeclarationDate As String
|
||||
|
||||
<JsonProperty("requestedProcedure")>
|
||||
Public Property RequestedProcedure As String
|
||||
|
||||
<JsonProperty("previousProcedure")>
|
||||
Public Property PreviousProcedure As String
|
||||
|
||||
<JsonProperty("goods")>
|
||||
Public Property Goods As List(Of GoodItem)
|
||||
End Class
|
||||
|
||||
Public Class GoodItem
|
||||
<JsonProperty("commodityCode")>
|
||||
Public Property CommodityCode As String
|
||||
|
||||
<JsonProperty("originCountryCode")>
|
||||
Public Property OriginCountryCode As String
|
||||
|
||||
<JsonProperty("netMass")>
|
||||
Public Property NetMass As String
|
||||
|
||||
<JsonProperty("typeOfMeasurementUnit")>
|
||||
Public Property TypeOfMeasurementUnit As String
|
||||
|
||||
<JsonProperty("specialProcedures")>
|
||||
Public Property SpecialProcedures As SpecialProceduresNode
|
||||
End Class
|
||||
|
||||
Public Class SpecialProceduresNode
|
||||
<JsonProperty("memberStateAutharization")>
|
||||
Public Property MemberStateAutharization As String
|
||||
|
||||
<JsonProperty("dischargeBillWaiver")>
|
||||
Public Property DischargeBillWaiver As String
|
||||
|
||||
<JsonProperty("authorisation")>
|
||||
Public Property Authorisation As String
|
||||
|
||||
<JsonProperty("startTime")>
|
||||
Public Property StartTime As String
|
||||
|
||||
<JsonProperty("endTime")>
|
||||
Public Property EndTime As String
|
||||
|
||||
<JsonProperty("deadline")>
|
||||
Public Property Deadline As String
|
||||
End Class
|
||||
|
||||
'--- parties ---
|
||||
Public Class PartiesNode
|
||||
<JsonProperty("importerIdentificationNumber")>
|
||||
Public Property ImporterIdentificationNumber As String
|
||||
|
||||
<JsonProperty("exporterIdentificationNumber")>
|
||||
Public Property ExporterIdentificationNumber As String
|
||||
|
||||
<JsonProperty("reportingDeclarantEORINumber")>
|
||||
Public Property ReportingDeclarantEORINumber As String
|
||||
|
||||
<JsonProperty("typeOfRepresentation")>
|
||||
Public Property TypeOfRepresentation As String
|
||||
End Class
|
||||
|
||||
'--- commercial ---
|
||||
Public Class CommercialNode
|
||||
<JsonProperty("invoiceNumbers")>
|
||||
Public Property InvoiceNumbers As String
|
||||
|
||||
<JsonProperty("invoiceDate")>
|
||||
Public Property InvoiceDate As String
|
||||
End Class
|
||||
|
||||
'--- exporterDetails ---
|
||||
Public Class ExporterDetailsNode
|
||||
<JsonProperty("exporterTitle")>
|
||||
Public Property ExporterTitle As String
|
||||
|
||||
<JsonProperty("exporterEmail")>
|
||||
Public Property ExporterEmail As String
|
||||
|
||||
<JsonProperty("exporterPhone")>
|
||||
Public Property ExporterPhone As String
|
||||
End Class
|
||||
|
||||
'--- importerDetails ---
|
||||
Public Class ImporterDetailsNode
|
||||
<JsonProperty("importerTitle")>
|
||||
Public Property ImporterTitle As String
|
||||
|
||||
<JsonProperty("importerEmail")>
|
||||
Public Property ImporterEmail As String
|
||||
|
||||
<JsonProperty("importerPhone")>
|
||||
Public Property ImporterPhone As String
|
||||
|
||||
<JsonProperty("importerCountryCodeOrMemberState")>
|
||||
Public Property ImporterCountryCodeOrMemberState As String
|
||||
|
||||
<JsonProperty("importerSubdivision")>
|
||||
Public Property ImporterSubdivision As String
|
||||
|
||||
<JsonProperty("importerCity")>
|
||||
Public Property ImporterCity As String
|
||||
|
||||
<JsonProperty("importerStreet")>
|
||||
Public Property ImporterStreet As String
|
||||
|
||||
<JsonProperty("importerStreetAdditional")>
|
||||
Public Property ImporterStreetAdditional As String
|
||||
|
||||
<JsonProperty("importerAddressNumber")>
|
||||
Public Property ImporterAddressNumber As String
|
||||
|
||||
<JsonProperty("importerPostCode")>
|
||||
Public Property ImporterPostCode As String
|
||||
|
||||
<JsonProperty("importerPoBox")>
|
||||
Public Property ImporterPoBox As String
|
||||
|
||||
<JsonProperty("importerCoordinateLongitudeX")>
|
||||
Public Property ImporterCoordinateLongitudeX As String
|
||||
|
||||
<JsonProperty("importerCoordinateLatitudeY")>
|
||||
Public Property ImporterCoordinateLatitudeY As String
|
||||
End Class
|
||||
|
||||
'========================
|
||||
'== Serialisierung
|
||||
'========================
|
||||
Public Function ToJson(Optional pretty As Boolean = True) As String
|
||||
Dim format = If(pretty, Formatting.Indented, Formatting.None)
|
||||
Return JsonConvert.SerializeObject(Me, format)
|
||||
End Function
|
||||
|
||||
'========================
|
||||
'== Beispielbefüllung
|
||||
'========================
|
||||
Public Shared Function BuildDemo() As cATEZ_Greenpulse_KafkaDecs
|
||||
Return New cATEZ_Greenpulse_KafkaDecs() With {
|
||||
.Declaration = New DeclarationNode() With {
|
||||
.DeclarationSourceId = "xx123",
|
||||
.DeclarationNo = "24AT000000INL0JD01",
|
||||
.DeclarationDate = "2024-11-22",
|
||||
.RequestedProcedure = "40",
|
||||
.PreviousProcedure = "00",
|
||||
.Goods = New List(Of GoodItem) From {
|
||||
New GoodItem() With {
|
||||
.CommodityCode = "72072710",
|
||||
.OriginCountryCode = "TR",
|
||||
.NetMass = "150",
|
||||
.TypeOfMeasurementUnit = "Tonnes",
|
||||
.SpecialProcedures = New SpecialProceduresNode() With {
|
||||
.MemberStateAutharization = "AT",
|
||||
.DischargeBillWaiver = "01",
|
||||
.Authorisation = "Name of authorisation",
|
||||
.StartTime = "2024-10-22",
|
||||
.EndTime = "2024-11-22",
|
||||
.Deadline = "2024-12-22"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
.Parties = New PartiesNode() With {
|
||||
.ImporterIdentificationNumber = "ATEOS1000000001",
|
||||
.ExporterIdentificationNumber = "FR123456789000",
|
||||
.ReportingDeclarantEORINumber = "ATEOS1000000002",
|
||||
.TypeOfRepresentation = "01"
|
||||
},
|
||||
.Commercial = New CommercialNode() With {
|
||||
.InvoiceNumbers = "123456789",
|
||||
.InvoiceDate = "2024-11-22"
|
||||
},
|
||||
.ExporterDetails = New ExporterDetailsNode() With {
|
||||
.ExporterTitle = "",
|
||||
.ExporterEmail = "",
|
||||
.ExporterPhone = ""
|
||||
},
|
||||
.ImporterDetails = New ImporterDetailsNode() With {
|
||||
.ImporterTitle = "Importer name",
|
||||
.ImporterEmail = "info@test.com",
|
||||
.ImporterPhone = "123456789",
|
||||
.ImporterCountryCodeOrMemberState = "DE",
|
||||
.ImporterSubdivision = "Sub-division",
|
||||
.ImporterCity = "City name",
|
||||
.ImporterStreet = "Street Name",
|
||||
.ImporterStreetAdditional = "Street additonal name",
|
||||
.ImporterAddressNumber = "10",
|
||||
.ImporterPostCode = "DCL-123",
|
||||
.ImporterPoBox = "PO DCL-123",
|
||||
.ImporterCoordinateLongitudeX = "41.0091982",
|
||||
.ImporterCoordinateLatitudeY = "28.9662187"
|
||||
}
|
||||
}
|
||||
End Function
|
||||
|
||||
'========================
|
||||
'== Unique-Key-Ermittlung (leer gelassen – später definieren)
|
||||
'========================
|
||||
Public Shared Function GetUniqueKey(ByVal record As cATEZ_Greenpulse_KafkaDecs) As String
|
||||
' TODO: Hier Logik zur Schlüsselbildung implementieren (z.B. declarationsourceId + declarationNo)
|
||||
Return ""
|
||||
End Function
|
||||
|
||||
'========================
|
||||
'== Kafka: Insert/Update (per Message-Key)
|
||||
'========================
|
||||
Public Shared Async Function InsertOrUpdateToKafkaAsync(ByVal record As cATEZ_Greenpulse_KafkaDecs,
|
||||
Optional ct As CancellationToken = Nothing) As Task(Of DeliveryResult(Of String, String))
|
||||
|
||||
Dim cfg As New ProducerConfig() With {
|
||||
.BootstrapServers = BootstrapServers,
|
||||
.Acks = Acks.All,
|
||||
.EnableIdempotence = True,
|
||||
.MessageTimeoutMs = 30000
|
||||
}
|
||||
|
||||
If UseSasl Then
|
||||
cfg.SecurityProtocol = SecurityProtocolSetting
|
||||
cfg.SaslMechanism = SaslMechanismSetting
|
||||
cfg.SaslUsername = SaslUsername
|
||||
cfg.SaslPassword = SaslPassword
|
||||
' Optional: cfg.SslCaLocation = "path\to\ca.pem"
|
||||
End If
|
||||
|
||||
Dim key As String = GetUniqueKey(record) ' bleibt leer bis du definierst
|
||||
Dim payload As String = record.ToJson(False)
|
||||
|
||||
Using producer As IProducer(Of String, String) = New ProducerBuilder(Of String, String)(cfg).Build()
|
||||
Dim msg As New Message(Of String, String) With {
|
||||
.key = key,
|
||||
.Value = payload
|
||||
}
|
||||
Dim result = Await producer.ProduceAsync(TopicName, msg, ct)
|
||||
' Flush ist bei Await ProduceAsync nicht zwingend nötig, hier dennoch zur Sicherheit:
|
||||
producer.Flush(TimeSpan.FromSeconds(5))
|
||||
Return result
|
||||
End Using
|
||||
End Function
|
||||
|
||||
'========================
|
||||
'== Sync-Wrapper (falls bevorzugt)
|
||||
'========================
|
||||
Public Shared Function InsertOrUpdateToKafka(ByVal record As cATEZ_Greenpulse_KafkaDecs) As DeliveryResult(Of String, String)
|
||||
Return InsertOrUpdateToKafkaAsync(record).GetAwaiter().GetResult()
|
||||
End Function
|
||||
|
||||
End Class
|
||||
|
||||
End Namespace
|
||||
@@ -21,7 +21,6 @@ Public Class cRelayHub
|
||||
Public Property declarationType As String
|
||||
Public Property referenceNumberOverlay As String ' <--- NEU
|
||||
Public Property username As String ' <--- NEU
|
||||
|
||||
End Class
|
||||
|
||||
Public Class cRelayHubDv1CostAllocation
|
||||
@@ -110,57 +109,68 @@ Public Class cRelayHub
|
||||
|
||||
Public Class cRelayHub_sendToRelayHub_JobOrderRequest
|
||||
|
||||
'Shared API_KEY = "2a6fe6bf-6547-4d56-b14a-8a18f94f9e94"
|
||||
'Shared API_URL = "dev-relayhub.singlewindow.io/api"
|
||||
Shared API_URL = "https://dev-relayhub.singlewindow.io/api/v1-0"
|
||||
Shared API_URL As String = "https://dev-relayhub.singlewindow.io/api/v1-0"
|
||||
|
||||
' Low-level Sender: holt Access-Token aus cRelayHubToken und sendet JSON
|
||||
Private Shared Function SendJobOrder(jsonPayload As String) As Chilkat.HttpResponse
|
||||
Dim http As New Chilkat.Http
|
||||
http.SetRequestHeader("Accept", "application/json")
|
||||
|
||||
' *** Token aus der separaten Token-Klasse beziehen ***
|
||||
Dim token As String = cRelayHubToken.GetValidAccessToken()
|
||||
http.AuthToken = token ' -> setzt Authorization: Bearer <token>
|
||||
|
||||
Return http.PostJson2(API_URL & "/job-orders/init", "application/json", jsonPayload)
|
||||
End Function
|
||||
|
||||
' Public API: erstellt Job-Order mit 401-Retry
|
||||
Public Shared Function query_declarations(request As cRelayHubJobOrderRequest) As cRelayHubApiResult
|
||||
Dim result As New cRelayHubApiResult()
|
||||
Try
|
||||
' This example assumes the Chilkat API to have been previously unlocked.
|
||||
' See Global Unlock Sample for sample code.
|
||||
VERAG_PROG_ALLGEMEIN.cChilkat_Helper.UnlockCilkat()
|
||||
|
||||
Dim success As Boolean
|
||||
|
||||
|
||||
' HTTP-Client initialisieren
|
||||
Dim http As New Chilkat.Http
|
||||
' JSON vorbereiten
|
||||
|
||||
' Request-Objekt in JSON-String umwandeln
|
||||
Dim jsonPayload As String = JsonConvert.SerializeObject(request)
|
||||
' MsgBox(jsonPayload)
|
||||
|
||||
|
||||
' Größe in Bytes
|
||||
Dim payloadSizeBytes As Integer = System.Text.Encoding.UTF8.GetByteCount(jsonPayload)
|
||||
|
||||
' Größe in Kilobytes (1 KB = 1024 Bytes)
|
||||
Dim payloadSizeKb As Double = payloadSizeBytes / 1024.0
|
||||
|
||||
Console.WriteLine("📦 Größe des JSON-Payload:")
|
||||
Console.WriteLine(payloadSizeBytes & " Bytes (" & Math.Round(payloadSizeKb, 2) & " KB)")
|
||||
|
||||
' Anfrage senden
|
||||
Dim response As Chilkat.HttpResponse = http.PostJson2(API_URL & "/job-orders/init", "application/json", jsonPayload)
|
||||
MsgBox(jsonPayload)
|
||||
|
||||
Console.WriteLine(jsonPayload)
|
||||
If http.LastMethodSuccess <> True Then
|
||||
result.Success = False
|
||||
result.StatusCode = 0
|
||||
result.Message = "Verbindungsfehler"
|
||||
result.Details = http.LastErrorText
|
||||
Return result
|
||||
' 1. Versuch
|
||||
Dim response As Chilkat.HttpResponse = SendJobOrder(jsonPayload)
|
||||
If response Is Nothing Then
|
||||
Return New cRelayHubApiResult With {
|
||||
.Success = False, .StatusCode = 0, .Message = "Verbindungsfehler",
|
||||
.Details = "Keine Antwort erhalten."
|
||||
}
|
||||
End If
|
||||
|
||||
result.StatusCode = response.StatusCode
|
||||
' 401 → Token-Cache invalidieren und genau 1x erneut probieren
|
||||
If response.StatusCode = 401 Then
|
||||
' WICHTIG:
|
||||
' Diese Methode sollte in cRelayHubToken als Public verfügbar sein:
|
||||
' Public Shared Sub ResetTokenCache() : ClearToken() : End Sub
|
||||
' → Falls noch nicht vorhanden, bitte dort ergänzen.
|
||||
Try
|
||||
cRelayHubToken.ResetTokenCache()
|
||||
Catch
|
||||
' Falls die Methode (noch) nicht existiert, kann man als Fallback
|
||||
' hier eine kurze Wartezeit einbauen und anschließend erneut GetValidAccessToken() aufrufen.
|
||||
' Threading.Thread.Sleep(100)
|
||||
End Try
|
||||
|
||||
' Retry
|
||||
response = SendJobOrder(jsonPayload)
|
||||
If response Is Nothing Then
|
||||
Return New cRelayHubApiResult With {
|
||||
.Success = False, .StatusCode = 0, .Message = "Verbindungsfehler (nach Refresh)",
|
||||
.Details = "Keine Antwort erhalten."
|
||||
}
|
||||
End If
|
||||
End If
|
||||
|
||||
' Auswertung
|
||||
result.StatusCode = response.StatusCode
|
||||
Select Case response.StatusCode
|
||||
Case 201
|
||||
Try
|
||||
Dim jobResponse As cRelayHubJobOrderResponse = JsonConvert.DeserializeObject(Of cRelayHubJobOrderResponse)(response.BodyStr)
|
||||
Dim jobResponse As cRelayHubJobOrderResponse =
|
||||
JsonConvert.DeserializeObject(Of cRelayHubJobOrderResponse)(response.BodyStr)
|
||||
result.Success = True
|
||||
result.Message = "Job Order erfolgreich erstellt"
|
||||
result.Data = jobResponse
|
||||
@@ -175,18 +185,12 @@ Public Class cRelayHub
|
||||
Case 400 To 499
|
||||
result.Success = False
|
||||
result.Message = "Client-Fehler"
|
||||
result.Message = "StatusCode: " & response.StatusCode
|
||||
result.Details = "StatusLine: " & response.StatusLine
|
||||
result.Details = "StatusText: " & response.StatusText
|
||||
result.Details = "BodyStr: " & response.BodyStr
|
||||
result.Details = response.BodyStr
|
||||
|
||||
Case 500 To 599
|
||||
result.Success = False
|
||||
result.Message = "Server-Fehler"
|
||||
result.Message = "StatusCode: " & response.StatusCode
|
||||
result.Details = "StatusLine: " & response.StatusLine
|
||||
result.Details = "StatusText: " & response.StatusText
|
||||
result.Details = "BodyStr: " & response.BodyStr
|
||||
result.Details = response.BodyStr
|
||||
|
||||
Case Else
|
||||
result.Success = False
|
||||
@@ -194,19 +198,15 @@ Public Class cRelayHub
|
||||
result.Details = response.BodyStr
|
||||
End Select
|
||||
|
||||
Console.WriteLine(result.Message)
|
||||
Console.WriteLine(result.Details)
|
||||
|
||||
Return result
|
||||
|
||||
|
||||
Catch ex As Exception
|
||||
VERAG_PROG_ALLGEMEIN.cErrorHandler.ERR(ex.Message, ex.StackTrace, System.Reflection.MethodInfo.GetCurrentMethod.Name)
|
||||
|
||||
Return New cRelayHubApiResult With {.Success = False, .StatusCode = 0, .Message = "Exception", .Details = ex.ToString()}
|
||||
End Try
|
||||
Return Nothing
|
||||
End Function
|
||||
|
||||
' Beispielfall
|
||||
Function CreateSampleJobOrderRequest() As cRelayHubJobOrderRequest
|
||||
Dim request As New cRelayHubJobOrderRequest With {
|
||||
.referenceNo = "1001K",
|
||||
@@ -262,9 +262,8 @@ Public Class cRelayHub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Return request
|
||||
End Function
|
||||
|
||||
End Class
|
||||
End Class
|
||||
End Class
|
||||
@@ -0,0 +1,245 @@
|
||||
Imports Newtonsoft.Json
|
||||
Imports System.IO
|
||||
Imports System.Text
|
||||
|
||||
Public Class cRelayHubToken
|
||||
|
||||
' === Token-Datenmodell ===
|
||||
Private Class TokenState
|
||||
Public AccessToken As String
|
||||
Public RefreshToken As String
|
||||
Public AccessExpiryUtc As DateTime
|
||||
Public RefreshExpiryUtc As DateTime
|
||||
End Class
|
||||
|
||||
' === Keycloak-Config ===
|
||||
Private Shared ReadOnly KC_BASE As String = "https://dev-kc.singlewindow.io"
|
||||
Private Shared ReadOnly KC_TOKEN_PATH As String = "/auth/realms/agsw/protocol/openid-connect/token"
|
||||
Private Shared ReadOnly KC_CLIENT_ID As String = "agsw-admin"
|
||||
Private Shared ReadOnly KC_USERNAME As String = "andreas.test@test.com"
|
||||
Private Shared ReadOnly KC_PASSWORD As String = "Password.123"
|
||||
Private Shared ReadOnly SKEW As TimeSpan = TimeSpan.FromSeconds(30)
|
||||
|
||||
' === Cache/Persistenz ===
|
||||
Private Shared _ts As TokenState = Nothing
|
||||
Private Shared ReadOnly TOKEN_FILE As String = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"RelayHub", "token.cache"
|
||||
)
|
||||
Private Shared ReadOnly _lockObj As New Object()
|
||||
|
||||
' -------------- DPAPI via Reflection (keine Compile-Abhängigkeit!) --------------
|
||||
Private Shared Function TryProtect(plain As Byte()) As Byte()
|
||||
Try
|
||||
' Versuche: Typen aus Assembly "System.Security" oder aus aktuellen Laufzeit-Assemblys laden
|
||||
Dim dpType As Type = Type.GetType("System.Security.Cryptography.ProtectedData, System.Security", throwOnError:=False)
|
||||
If dpType Is Nothing Then
|
||||
dpType = Type.GetType("System.Security.Cryptography.ProtectedData", throwOnError:=False)
|
||||
End If
|
||||
Dim scopeType As Type = Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security", throwOnError:=False)
|
||||
If dpType Is Nothing OrElse scopeType Is Nothing Then Return Nothing
|
||||
|
||||
Dim scopeObj As Object = [Enum].Parse(scopeType, "CurrentUser")
|
||||
Dim mi = dpType.GetMethod("Protect", New Type() {GetType(Byte()), GetType(Byte()), scopeType})
|
||||
If mi Is Nothing Then Return Nothing
|
||||
|
||||
Dim res = mi.Invoke(Nothing, New Object() {plain, Nothing, scopeObj})
|
||||
Return TryCast(res, Byte())
|
||||
Catch
|
||||
Return Nothing
|
||||
End Try
|
||||
End Function
|
||||
|
||||
Private Shared Function TryUnprotect(protectedBytes As Byte()) As Byte()
|
||||
Try
|
||||
Dim dpType As Type = Type.GetType("System.Security.Cryptography.ProtectedData, System.Security", throwOnError:=False)
|
||||
If dpType Is Nothing Then
|
||||
dpType = Type.GetType("System.Security.Cryptography.ProtectedData", throwOnError:=False)
|
||||
End If
|
||||
Dim scopeType As Type = Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security", throwOnError:=False)
|
||||
If dpType Is Nothing OrElse scopeType Is Nothing Then Return Nothing
|
||||
|
||||
Dim scopeObj As Object = [Enum].Parse(scopeType, "CurrentUser")
|
||||
Dim mi = dpType.GetMethod("Unprotect", New Type() {GetType(Byte()), GetType(Byte()), scopeType})
|
||||
If mi Is Nothing Then Return Nothing
|
||||
|
||||
Dim res = mi.Invoke(Nothing, New Object() {protectedBytes, Nothing, scopeObj})
|
||||
Return TryCast(res, Byte())
|
||||
Catch
|
||||
Return Nothing
|
||||
End Try
|
||||
End Function
|
||||
|
||||
' -------------- Persistenz: bevorzugt DPAPI, Fallback Plain-File --------------
|
||||
Private Shared Sub SaveTokenSecure(ts As TokenState)
|
||||
Try
|
||||
Dim dir = Path.GetDirectoryName(TOKEN_FILE)
|
||||
If Not Directory.Exists(dir) Then Directory.CreateDirectory(dir)
|
||||
|
||||
Dim payload As String = String.Join(vbLf, {
|
||||
ts.AccessToken,
|
||||
ts.RefreshToken,
|
||||
ts.AccessExpiryUtc.Ticks.ToString(),
|
||||
ts.RefreshExpiryUtc.Ticks.ToString()
|
||||
})
|
||||
Dim plain = Encoding.UTF8.GetBytes(payload)
|
||||
|
||||
Dim protectedBytes = TryProtect(plain)
|
||||
If protectedBytes IsNot Nothing Then
|
||||
File.WriteAllBytes(TOKEN_FILE, protectedBytes)
|
||||
Else
|
||||
' Fallback (nur zu Testzwecken!)
|
||||
File.WriteAllText(TOKEN_FILE, payload, Encoding.UTF8)
|
||||
End If
|
||||
Catch
|
||||
' optional loggen
|
||||
End Try
|
||||
End Sub
|
||||
|
||||
Private Shared Function LoadTokenSecure() As TokenState
|
||||
Try
|
||||
If Not File.Exists(TOKEN_FILE) Then Return Nothing
|
||||
|
||||
' Zuerst versuchen wir, als DPAPI-Bytes zu lesen und zu entschlüsseln
|
||||
Dim raw = File.ReadAllBytes(TOKEN_FILE)
|
||||
Dim plain = TryUnprotect(raw)
|
||||
|
||||
Dim content As String
|
||||
If plain Is Nothing Then
|
||||
' Fallback: als Text lesen (falls zuvor ohne DPAPI gespeichert)
|
||||
content = File.ReadAllText(TOKEN_FILE, Encoding.UTF8)
|
||||
Else
|
||||
content = Encoding.UTF8.GetString(plain)
|
||||
End If
|
||||
|
||||
Dim s = content.Split({vbLf}, StringSplitOptions.None)
|
||||
If s.Length < 4 Then Return Nothing
|
||||
Return New TokenState With {
|
||||
.AccessToken = s(0),
|
||||
.RefreshToken = s(1),
|
||||
.AccessExpiryUtc = New DateTime(Long.Parse(s(2)), DateTimeKind.Utc),
|
||||
.RefreshExpiryUtc = New DateTime(Long.Parse(s(3)), DateTimeKind.Utc)
|
||||
}
|
||||
Catch
|
||||
Return Nothing
|
||||
End Try
|
||||
End Function
|
||||
|
||||
Private Shared Sub ClearToken()
|
||||
SyncLock _lockObj
|
||||
_ts = Nothing
|
||||
Try
|
||||
If File.Exists(TOKEN_FILE) Then File.Delete(TOKEN_FILE)
|
||||
Catch
|
||||
End Try
|
||||
End SyncLock
|
||||
End Sub
|
||||
|
||||
' -------------- Utilities --------------
|
||||
Private Shared Function UtcNow() As DateTime
|
||||
Return DateTime.UtcNow
|
||||
End Function
|
||||
|
||||
Private Shared Function IsAccessValid(ts As TokenState) As Boolean
|
||||
Return ts IsNot Nothing AndAlso Not String.IsNullOrEmpty(ts.AccessToken) AndAlso UtcNow() < ts.AccessExpiryUtc - SKEW
|
||||
End Function
|
||||
|
||||
Private Shared Function IsRefreshValid(ts As TokenState) As Boolean
|
||||
Return ts IsNot Nothing AndAlso Not String.IsNullOrEmpty(ts.RefreshToken) AndAlso UtcNow() < ts.RefreshExpiryUtc - SKEW
|
||||
End Function
|
||||
|
||||
' -------------- OAuth Flows --------------
|
||||
Private Shared Function PasswordLogin() As TokenState
|
||||
Dim http As New Chilkat.Http
|
||||
Dim req As New Chilkat.HttpRequest
|
||||
req.HttpVerb = "POST"
|
||||
req.Path = KC_TOKEN_PATH
|
||||
req.AddParam("grant_type", "password")
|
||||
req.AddParam("username", KC_USERNAME)
|
||||
req.AddParam("password", KC_PASSWORD)
|
||||
req.AddParam("client_id", KC_CLIENT_ID)
|
||||
req.AddParam("scope", "openid offline_access")
|
||||
req.AddHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
Dim resp = http.PostUrlEncoded(KC_BASE, req)
|
||||
If resp Is Nothing Then Throw New Exception("Token-Request fehlgeschlagen: " & http.LastErrorText)
|
||||
If resp.StatusCode <> 200 Then Throw New Exception("Password-Grant fehlgeschlagen: " & resp.StatusCode & " - " & resp.BodyStr)
|
||||
|
||||
Dim json As New Chilkat.JsonObject : json.Load(resp.BodyStr)
|
||||
Dim access = json.StringOf("access_token")
|
||||
Dim refresh = json.StringOf("refresh_token")
|
||||
Dim exp = Math.Max(60, json.IntOf("expires_in"))
|
||||
Dim rexp = Math.Max(300, json.IntOf("refresh_expires_in"))
|
||||
|
||||
Dim ts = New TokenState With {
|
||||
.AccessToken = access,
|
||||
.RefreshToken = refresh,
|
||||
.AccessExpiryUtc = UtcNow().AddSeconds(exp),
|
||||
.RefreshExpiryUtc = UtcNow().AddSeconds(rexp)
|
||||
}
|
||||
SaveTokenSecure(ts)
|
||||
Return ts
|
||||
End Function
|
||||
|
||||
Private Shared Function RefreshLogin(oldTs As TokenState) As TokenState
|
||||
If oldTs Is Nothing OrElse String.IsNullOrEmpty(oldTs.RefreshToken) Then
|
||||
Throw New Exception("Kein gültiger Refresh-Token vorhanden.")
|
||||
End If
|
||||
|
||||
Dim http As New Chilkat.Http
|
||||
Dim req As New Chilkat.HttpRequest
|
||||
req.HttpVerb = "POST"
|
||||
req.Path = KC_TOKEN_PATH
|
||||
req.AddParam("grant_type", "refresh_token")
|
||||
req.AddParam("refresh_token", oldTs.RefreshToken)
|
||||
req.AddParam("client_id", KC_CLIENT_ID)
|
||||
req.AddHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
Dim resp = http.PostUrlEncoded(KC_BASE, req)
|
||||
If resp Is Nothing Then Throw New Exception("Refresh-Request fehlgeschlagen: " & http.LastErrorText)
|
||||
If resp.StatusCode <> 200 Then Throw New Exception("Refresh fehlgeschlagen: " & resp.StatusCode & " - " & resp.BodyStr)
|
||||
|
||||
Dim json As New Chilkat.JsonObject : json.Load(resp.BodyStr)
|
||||
Dim access = json.StringOf("access_token")
|
||||
Dim refresh = json.StringOf("refresh_token") ' Rotation beachten
|
||||
Dim exp = Math.Max(60, json.IntOf("expires_in"))
|
||||
Dim rexp = Math.Max(300, json.IntOf("refresh_expires_in"))
|
||||
|
||||
Dim ts = New TokenState With {
|
||||
.AccessToken = access,
|
||||
.RefreshToken = refresh,
|
||||
.AccessExpiryUtc = UtcNow().AddSeconds(exp),
|
||||
.RefreshExpiryUtc = UtcNow().AddSeconds(rexp)
|
||||
}
|
||||
SaveTokenSecure(ts)
|
||||
Return ts
|
||||
End Function
|
||||
|
||||
' -------------- Public API --------------
|
||||
Public Shared Function GetValidAccessToken() As String
|
||||
SyncLock _lockObj
|
||||
If _ts Is Nothing Then _ts = LoadTokenSecure()
|
||||
|
||||
If IsAccessValid(_ts) Then
|
||||
Return _ts.AccessToken
|
||||
End If
|
||||
|
||||
If IsRefreshValid(_ts) Then
|
||||
Try
|
||||
_ts = RefreshLogin(_ts)
|
||||
Return _ts.AccessToken
|
||||
Catch
|
||||
' fällt durch auf PasswordLogin
|
||||
End Try
|
||||
End If
|
||||
|
||||
_ts = PasswordLogin()
|
||||
Return _ts.AccessToken
|
||||
End SyncLock
|
||||
End Function
|
||||
|
||||
Public Shared Sub ResetTokenCache()
|
||||
ClearToken()
|
||||
End Sub
|
||||
|
||||
End Class
|
||||
Reference in New Issue
Block a user