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.
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
- extract_form_action_url(response.body)
86
+ extract_login_form_action_url(response.body) || raise('Login form not found')
85
87
  end
86
88
 
87
- def extract_form_action_url(html)
88
- forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi)
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
- return CGI.unescapeHTML(action_url) if has_username && has_password
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
- # :nocov:
98
- raise 'Login form not found'
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 submit_credentials(form_url)
103
- credentials = { username:, password: }
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
- response.headers['location'] || raise('No redirect location')
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 ensure_token_valid
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 true unless oauth_token.expired?
174
+ return unless oauth_token.expired?
122
175
 
123
176
  self.oauth_token = oauth_token.refresh!
124
- true
125
177
  rescue StandardError => e
126
- # :nocov:
127
- warn "Token refresh failed: #{e.message}"
128
- false
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, default: nil)
133
- return default unless ensure_token_valid
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
- default
191
+ nil
143
192
  # :nocov:
144
193
  end
145
194
 
146
- def post(url, data, default: nil)
147
- return default unless ensure_token_valid
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
- default
208
+ nil
161
209
  # :nocov:
162
210
  end
163
211
 
data/lib/senec/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Senec
2
- VERSION = '0.21.0'.freeze
2
+ VERSION = '0.22.1'.freeze
3
3
  end
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.21.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