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.
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,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
@@ -1,3 +1,3 @@
1
1
  module Senec
2
- VERSION = '0.21.0'.freeze
2
+ VERSION = '0.22.0'.freeze
3
3
  end
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.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
@@ -1 +0,0 @@
1
- 2025-07-29T09:58:37+02:00