senec 0.21.0 → 0.22.1
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 +64 -59
- data/.rubocop.yml +3 -0
- data/.ruby-lsp/Gemfile.lock +21 -21
- data/.ruby-lsp/last_updated +1 -1
- data/.ruby-lsp/main_lockfile_hash +1 -1
- data/coverage/.last_run.json +1 -1
- data/coverage/.resultset.json +167 -64
- data/coverage/coverage.json +170 -67
- data/coverage/index.html +3083 -1949
- data/lib/senec/.DS_Store +0 -0
- data/lib/senec/cloud/connection.rb +82 -34
- data/lib/senec/version.rb +1 -1
- data/pkg/senec-0.21.0.gem +0 -0
- data/pkg/senec-0.22.0.gem +0 -0
- metadata +17 -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,48 +124,88 @@ module Senec
|
|
116
124
|
params['code'] || raise('No authorization code found')
|
117
125
|
end
|
118
126
|
|
119
|
-
def
|
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
|
+
|
172
|
+
def ensure_token_valid!
|
120
173
|
authenticate! unless authenticated?
|
121
|
-
return
|
174
|
+
return unless oauth_token.expired?
|
122
175
|
|
123
176
|
self.oauth_token = oauth_token.refresh!
|
124
|
-
true
|
125
177
|
rescue StandardError => e
|
126
|
-
#
|
127
|
-
|
128
|
-
|
129
|
-
# :nocov:
|
178
|
+
warn "Token refresh failed: #{e.message}, trying to re-authenticate..."
|
179
|
+
|
180
|
+
authenticate!
|
130
181
|
end
|
131
182
|
|
132
|
-
def get(url
|
133
|
-
|
183
|
+
def get(url)
|
184
|
+
ensure_token_valid!
|
134
185
|
|
135
186
|
response = oauth_token.get(url)
|
136
|
-
return default unless response.status == 200
|
137
|
-
|
138
187
|
JSON.parse(response.body)
|
139
188
|
rescue StandardError => e
|
140
189
|
# :nocov:
|
141
190
|
warn "API error: #{e.message}"
|
142
|
-
|
191
|
+
nil
|
143
192
|
# :nocov:
|
144
193
|
end
|
145
194
|
|
146
|
-
def post(url, data
|
147
|
-
|
195
|
+
def post(url, data)
|
196
|
+
ensure_token_valid!
|
148
197
|
|
149
198
|
response = oauth_token.post(
|
150
199
|
url,
|
151
200
|
body: data.to_json,
|
152
201
|
headers: { 'Content-Type' => 'application/json' },
|
153
202
|
)
|
154
|
-
return default unless response.status == 200
|
155
203
|
|
156
204
|
JSON.parse(response.body)
|
157
205
|
rescue StandardError => e
|
158
206
|
# :nocov:
|
159
207
|
warn "API error: #{e.message}"
|
160
|
-
|
208
|
+
nil
|
161
209
|
# :nocov:
|
162
210
|
end
|
163
211
|
|
data/lib/senec/version.rb
CHANGED
Binary file
|
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.1
|
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
|
@@ -150,6 +164,8 @@ files:
|
|
150
164
|
- pkg/senec-0.19.0.gem
|
151
165
|
- pkg/senec-0.2.0.gem
|
152
166
|
- pkg/senec-0.20.0.gem
|
167
|
+
- pkg/senec-0.21.0.gem
|
168
|
+
- pkg/senec-0.22.0.gem
|
153
169
|
- pkg/senec-0.3.0.gem
|
154
170
|
- pkg/senec-0.4.0.gem
|
155
171
|
- pkg/senec-0.5.0.gem
|