senec 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env +2 -1
- data/.env.test +1 -0
- data/.rspec_status +61 -59
- data/.ruby-lsp/Gemfile.lock +6 -6
- data/.ruby-lsp/main_lockfile_hash +1 -1
- data/coverage/.last_run.json +1 -1
- data/coverage/.resultset.json +122 -54
- data/coverage/coverage.json +123 -55
- data/coverage/index.html +1449 -713
- data/lib/senec/.DS_Store +0 -0
- data/lib/senec/cloud/connection.rb +71 -18
- data/lib/senec/version.rb +1 -1
- data/pkg/senec-0.21.0.gem +0 -0
- metadata +16 -2
- data/.ruby-lsp/last_updated +0 -1
data/lib/senec/.DS_Store
CHANGED
Binary file
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'oauth2'
|
2
|
+
require 'rotp'
|
2
3
|
|
3
4
|
module Senec
|
4
5
|
module Cloud
|
@@ -16,13 +17,14 @@ module Senec
|
|
16
17
|
class Connection
|
17
18
|
DEFAULT_USER_AGENT = "ruby-senec/#{Senec::VERSION} (+https://github.com/solectrus/senec)".freeze
|
18
19
|
|
19
|
-
def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT)
|
20
|
+
def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT, totp_uri: nil)
|
20
21
|
@username = username
|
21
22
|
@password = password
|
22
23
|
@user_agent = user_agent
|
24
|
+
@totp_uri = totp_uri
|
23
25
|
end
|
24
26
|
|
25
|
-
attr_reader :username, :password, :user_agent
|
27
|
+
attr_reader :username, :password, :user_agent, :totp_uri
|
26
28
|
|
27
29
|
def authenticate!
|
28
30
|
code_verifier = SecureRandom.alphanumeric(43)
|
@@ -81,30 +83,36 @@ module Senec
|
|
81
83
|
def fetch_login_form_url(auth_url)
|
82
84
|
response = http_request(:get, auth_url)
|
83
85
|
store_cookies(response) # Required for Keycloak CSRF protection
|
84
|
-
|
86
|
+
extract_login_form_action_url(response.body) || raise('Login form not found')
|
85
87
|
end
|
86
88
|
|
87
|
-
def
|
88
|
-
|
89
|
-
|
90
|
-
forms.each do |action_url, form_content|
|
91
|
-
has_username = form_content.match(/name=["']?username["']?/i)
|
92
|
-
has_password = form_content.match(/name=["']?password["']?/i)
|
89
|
+
def submit_credentials(form_url)
|
90
|
+
credentials = { username:, password: }
|
91
|
+
response = http_request(:post, form_url, data: credentials)
|
93
92
|
|
94
|
-
|
93
|
+
if response.status == 200
|
94
|
+
# Check if MFA is required
|
95
|
+
if (totp_form_url = extract_totp_form_action_url(response.body))
|
96
|
+
unless totp_uri
|
97
|
+
raise 'MFA required but no TOTP URI provided'
|
98
|
+
end
|
99
|
+
|
100
|
+
response = submit_totp_form(totp_form_url)
|
101
|
+
else
|
102
|
+
# No OTP form found, assume login failed
|
103
|
+
raise 'Login failed - invalid credentials or unexpected response'
|
104
|
+
end
|
95
105
|
end
|
96
106
|
|
97
|
-
#
|
98
|
-
|
99
|
-
# :nocov:
|
107
|
+
# Handle final redirect (same for both MFA and non-MFA flows)
|
108
|
+
handle_redirect_response(response)
|
100
109
|
end
|
101
110
|
|
102
|
-
def
|
103
|
-
|
104
|
-
response = http_request(:post, form_url, data: credentials)
|
105
|
-
raise 'Login failed' unless response.status == 302
|
111
|
+
def submit_totp_form(form_url)
|
112
|
+
totp = build_totp_from_uri(totp_uri)
|
106
113
|
|
107
|
-
|
114
|
+
totp_data = { otp: totp.now }
|
115
|
+
http_request(:post, form_url, data: totp_data)
|
108
116
|
end
|
109
117
|
|
110
118
|
def extract_authorization_code(redirect_url)
|
@@ -116,6 +124,51 @@ module Senec
|
|
116
124
|
params['code'] || raise('No authorization code found')
|
117
125
|
end
|
118
126
|
|
127
|
+
def extract_login_form_action_url(html)
|
128
|
+
find_form_action_url(html) do |form|
|
129
|
+
# Look for a form with username and password fields
|
130
|
+
form.match(/name=["']?username["']?/i) &&
|
131
|
+
form.match(/name=["']?password["']?/i)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def extract_totp_form_action_url(html)
|
136
|
+
find_form_action_url(html) do |form|
|
137
|
+
# Look for a form with an OTP field
|
138
|
+
form.match(/name=["']?otp["']?/i)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def find_form_action_url(html)
|
143
|
+
forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
|
144
|
+
|
145
|
+
forms.each do |action_url, form_content|
|
146
|
+
return CGI.unescapeHTML(action_url) if yield(form_content)
|
147
|
+
end
|
148
|
+
|
149
|
+
nil
|
150
|
+
end
|
151
|
+
|
152
|
+
def build_totp_from_uri(uri_string)
|
153
|
+
params = URI.decode_www_form(URI.parse(uri_string).query).to_h
|
154
|
+
secret = params['secret'] || raise('Missing secret in TOTP URI')
|
155
|
+
|
156
|
+
ROTP::TOTP.new(
|
157
|
+
secret,
|
158
|
+
digits: params['digits']&.to_i,
|
159
|
+
period: params['period']&.to_i,
|
160
|
+
algorithm: params['algorithm'],
|
161
|
+
)
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_redirect_response(response)
|
165
|
+
unless response.status == 302
|
166
|
+
raise "Authentication failed, got unexpected response: #{response.status} #{response.body}"
|
167
|
+
end
|
168
|
+
|
169
|
+
response.headers['location'] || raise('No redirect location')
|
170
|
+
end
|
171
|
+
|
119
172
|
def ensure_token_valid
|
120
173
|
authenticate! unless authenticated?
|
121
174
|
return true unless oauth_token.expired?
|
data/lib/senec/version.rb
CHANGED
Binary file
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: senec
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.22.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Georg Ledermann
|
@@ -65,6 +65,20 @@ dependencies:
|
|
65
65
|
- - ">="
|
66
66
|
- !ruby/object:Gem::Version
|
67
67
|
version: '0'
|
68
|
+
- !ruby/object:Gem::Dependency
|
69
|
+
name: rotp
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
type: :runtime
|
76
|
+
prerelease: false
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
68
82
|
description: Access your local SENEC Solar Battery Storage System
|
69
83
|
email:
|
70
84
|
- georg@ledermann.dev
|
@@ -82,7 +96,6 @@ files:
|
|
82
96
|
- ".ruby-lsp/.gitignore"
|
83
97
|
- ".ruby-lsp/Gemfile"
|
84
98
|
- ".ruby-lsp/Gemfile.lock"
|
85
|
-
- ".ruby-lsp/last_updated"
|
86
99
|
- ".ruby-lsp/main_lockfile_hash"
|
87
100
|
- ".ruby-lsp/needs_update"
|
88
101
|
- ".vscode/settings.json"
|
@@ -150,6 +163,7 @@ files:
|
|
150
163
|
- pkg/senec-0.19.0.gem
|
151
164
|
- pkg/senec-0.2.0.gem
|
152
165
|
- pkg/senec-0.20.0.gem
|
166
|
+
- pkg/senec-0.21.0.gem
|
153
167
|
- pkg/senec-0.3.0.gem
|
154
168
|
- pkg/senec-0.4.0.gem
|
155
169
|
- pkg/senec-0.5.0.gem
|
data/.ruby-lsp/last_updated
DELETED
@@ -1 +0,0 @@
|
|
1
|
-
2025-07-29T09:58:37+02:00
|