|
1 | | -require 'faraday' |
2 | | -require 'json' |
| 1 | +require 'oauth2' |
3 | 2 |
|
4 | 3 | module Senec |
5 | 4 | module Cloud |
6 | | - BASE_URL = 'https://mein-senec.de'.freeze |
| 5 | + CONFIG_URL = |
| 6 | + 'https://sso.senec.com/realms/senec/.well-known/openid-configuration'.freeze |
| 7 | + |
| 8 | + CLIENT_ID = 'endcustomer-app-frontend'.freeze |
| 9 | + REDIRECT_URI = 'senec-app-auth://keycloak.prod'.freeze |
| 10 | + SCOPE = 'roles meinsenec'.freeze |
| 11 | + |
| 12 | + SYSTEMS_HOST = 'https://senec-app-systems-proxy.prod.senec.dev'.freeze |
| 13 | + MEASUREMENTS_HOST = 'https://senec-app-measurements-proxy.prod.senec.dev'.freeze |
| 14 | + WALLBOX_HOST = 'https://senec-app-wallbox-proxy.prod.senec.dev'.freeze |
7 | 15 |
|
8 | 16 | class Connection |
9 | 17 | DEFAULT_USER_AGENT = "ruby-senec/#{Senec::VERSION} (+https://github.com/solectrus/senec)".freeze |
10 | | - MAX_REDIRECTS = 10 |
11 | 18 |
|
12 | 19 | def initialize(username:, password:, user_agent: DEFAULT_USER_AGENT) |
13 | 20 | @username = username |
14 | 21 | @password = password |
15 | 22 | @user_agent = user_agent |
16 | | - @cookies = {} |
17 | 23 | end |
18 | 24 |
|
19 | | - attr_reader :username, :password, :user_agent, :cookies |
| 25 | + attr_reader :username, :password, :user_agent |
| 26 | + |
| 27 | + def authenticate! |
| 28 | + code_verifier = SecureRandom.alphanumeric(43) |
| 29 | + digest = Digest::SHA256.digest(code_verifier) |
| 30 | + code_challenge = Base64.urlsafe_encode64(digest).delete('=') |
| 31 | + |
| 32 | + auth_url = |
| 33 | + oauth_client.auth_code.authorize_url( |
| 34 | + redirect_uri: REDIRECT_URI, |
| 35 | + scope: SCOPE, |
| 36 | + code_challenge:, |
| 37 | + code_challenge_method: 'S256', |
| 38 | + ) |
| 39 | + |
| 40 | + # Manual HTTP needed for Keycloak cross-domain form handling |
| 41 | + login_form_url = fetch_login_form_url(auth_url) |
| 42 | + redirect_url = submit_credentials(login_form_url) |
| 43 | + authorization_code = extract_authorization_code(redirect_url) |
| 44 | + |
| 45 | + self.oauth_token = |
| 46 | + oauth_client.auth_code.get_token( |
| 47 | + authorization_code, |
| 48 | + redirect_uri: REDIRECT_URI, |
| 49 | + code_verifier:, |
| 50 | + ) |
| 51 | + end |
20 | 52 |
|
21 | 53 | def authenticated? |
22 | | - authenticate if cookies.empty? |
23 | | - |
24 | | - cookies.key?('sso.senec.com_KEYCLOAK_IDENTITY') |
| 54 | + !!oauth_token |
25 | 55 | end |
26 | 56 |
|
27 | | - def authenticate |
28 | | - response = request_with_redirects(Cloud::BASE_URL) |
| 57 | + def systems |
| 58 | + fetch_payload "#{SYSTEMS_HOST}/v1/systems" |
| 59 | + end |
29 | 60 |
|
30 | | - # Find form with username and password inputs |
31 | | - form_match = find_login_form(response.body) |
32 | | - raise Error, 'Login form not found!' unless form_match |
| 61 | + def system_details(system_id) |
| 62 | + fetch_payload "#{SYSTEMS_HOST}/systems/#{system_id}/details" |
| 63 | + end |
33 | 64 |
|
34 | | - # Perform the login request with the extracted form action URL |
35 | | - form_action = form_match.gsub('&', '&') |
36 | | - request_with_redirects( |
37 | | - form_action, { |
38 | | - 'username' => username, |
39 | | - 'password' => password |
40 | | - }, |
41 | | - ) |
| 65 | + def dashboard(system_id) |
| 66 | + fetch_payload "#{MEASUREMENTS_HOST}/v1/systems/#{system_id}/dashboard" |
42 | 67 | end |
43 | 68 |
|
44 | | - def simple_request(url) |
45 | | - perform_request(url) |
| 69 | + def wallbox(system_id, wallbox_id) |
| 70 | + fetch_payload "#{WALLBOX_HOST}/v1/systems/#{system_id}/wallboxes/#{wallbox_id}" |
46 | 71 | end |
47 | 72 |
|
48 | | - def request_with_redirects(url, data = nil) |
49 | | - uri = URI(url) |
50 | | - redirect_count = 0 |
51 | | - response = nil |
| 73 | + private |
52 | 74 |
|
53 | | - loop do |
54 | | - response = perform_request(uri.to_s, data) |
55 | | - store_cookies(response) |
| 75 | + attr_accessor :oauth_token |
56 | 76 |
|
57 | | - break unless (300..399).cover?(response.status) |
| 77 | + def fetch_login_form_url(auth_url) |
| 78 | + response = http_request(:get, auth_url) |
| 79 | + store_cookies(response) # Required for Keycloak CSRF protection |
| 80 | + extract_form_action_url(response.body) |
| 81 | + end |
58 | 82 |
|
59 | | - location = response.headers['location'] |
60 | | - break unless location |
| 83 | + def extract_form_action_url(html) |
| 84 | + forms = html.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}mi) |
61 | 85 |
|
62 | | - redirect_count += 1 |
63 | | - raise 'Too many redirects!' if redirect_count > MAX_REDIRECTS |
| 86 | + forms.each do |action_url, form_content| |
| 87 | + has_username = form_content.match(/name=["']?username["']?/i) |
| 88 | + has_password = form_content.match(/name=["']?password["']?/i) |
64 | 89 |
|
65 | | - uri = location.start_with?('http') ? URI(location) : URI.join(uri, location) |
66 | | - data = nil # Clear data after first request (no POST redirects) |
| 90 | + return CGI.unescapeHTML(action_url) if has_username && has_password |
67 | 91 | end |
68 | 92 |
|
69 | | - response |
| 93 | + # :nocov: |
| 94 | + raise 'Login form not found' |
| 95 | + # :nocov: |
70 | 96 | end |
71 | 97 |
|
72 | | - private |
| 98 | + def submit_credentials(form_url) |
| 99 | + credentials = { username:, password: } |
| 100 | + response = http_request(:post, form_url, data: credentials) |
| 101 | + raise 'Login failed' unless response.status == 302 |
73 | 102 |
|
74 | | - def find_login_form(html_body) |
75 | | - # Find all forms and check if they contain both username and password inputs |
76 | | - html_body.scan(%r{<form[^>]*action="([^"]+)"[^>]*>(.*?)</form>}m).each do |action, form_content| |
77 | | - has_username = form_content.match(/input[^>]*name=["']username["'][^>]*/) |
78 | | - has_password = form_content.match(/input[^>]*name=["']password["'][^>]*/) |
| 103 | + response.headers['location'] || raise('No redirect location') |
| 104 | + end |
79 | 105 |
|
80 | | - return action if has_username && has_password |
81 | | - end |
| 106 | + def extract_authorization_code(redirect_url) |
| 107 | + raise 'Invalid redirect URL' unless redirect_url&.start_with?(REDIRECT_URI) |
| 108 | + |
| 109 | + uri = URI(redirect_url) |
| 110 | + params = URI.decode_www_form(uri.query).to_h |
| 111 | + |
| 112 | + params['code'] || raise('No authorization code found') |
| 113 | + end |
| 114 | + |
| 115 | + def ensure_token_valid |
| 116 | + authenticate! unless authenticated? |
| 117 | + return true unless oauth_token.expired? |
82 | 118 |
|
| 119 | + self.oauth_token = oauth_token.refresh! |
| 120 | + true |
| 121 | + rescue StandardError => e |
83 | 122 | # :nocov: |
84 | | - nil |
| 123 | + warn "Token refresh failed: #{e.message}" |
| 124 | + false |
85 | 125 | # :nocov: |
86 | 126 | end |
87 | 127 |
|
88 | | - def faraday |
89 | | - @faraday ||= Faraday.new do |f| |
90 | | - f.adapter :net_http_persistent, pool_size: 1 do |http| |
91 | | - # :nocov: |
92 | | - http.idle_timeout = 400 |
93 | | - # :nocov: |
94 | | - end |
95 | | - end |
| 128 | + def fetch_payload(url, default = nil) |
| 129 | + return default unless ensure_token_valid |
| 130 | + |
| 131 | + response = oauth_token.get(url) |
| 132 | + return default unless response.status == 200 |
| 133 | + |
| 134 | + JSON.parse(response.body) |
| 135 | + rescue StandardError => e |
| 136 | + # :nocov: |
| 137 | + warn "API error: #{e.message}" |
| 138 | + default |
| 139 | + # :nocov: |
96 | 140 | end |
97 | 141 |
|
98 | | - def perform_request(url, data = nil) |
99 | | - method = data ? :post : :get |
100 | | - faraday.public_send(method, url) do |req| |
101 | | - configure_request_headers(req) |
102 | | - if method == :post |
103 | | - req.body = URI.encode_www_form(data) |
104 | | - req.headers['content-type'] = 'application/x-www-form-urlencoded' |
| 142 | + def http_request(method, url, data: nil) |
| 143 | + Faraday |
| 144 | + .new |
| 145 | + .send(method, url) do |req| |
| 146 | + req.headers['user-agent'] = user_agent |
| 147 | + req.headers['connection'] = 'keep-alive' |
| 148 | + req.headers['cookie'] = cookie_string if cookies.any? |
| 149 | + req.body = URI.encode_www_form(data) if data |
105 | 150 | end |
106 | | - end |
107 | 151 | end |
108 | 152 |
|
109 | | - def configure_request_headers(request) |
110 | | - request.headers['user-agent'] = user_agent |
111 | | - request.headers['connection'] = 'keep-alive' |
112 | | - request.headers['cookie'] = cookies.values.join('; ') unless cookies.empty? |
| 153 | + def oauth_client |
| 154 | + @oauth_client ||= |
| 155 | + OAuth2::Client.new( |
| 156 | + CLIENT_ID, |
| 157 | + nil, |
| 158 | + site: openid_config['issuer'], |
| 159 | + authorize_url: openid_config['authorization_endpoint'], |
| 160 | + token_url: openid_config['token_endpoint'], |
| 161 | + ) |
| 162 | + end |
| 163 | + |
| 164 | + def openid_config |
| 165 | + @openid_config ||= JSON.parse(http_request(:get, CONFIG_URL).body) |
| 166 | + rescue StandardError => e |
| 167 | + # :nocov: |
| 168 | + raise "Failed to load OpenID configuration: #{e.message}" |
| 169 | + # :nocov: |
| 170 | + end |
| 171 | + |
| 172 | + def cookies |
| 173 | + @cookies ||= {} |
| 174 | + end |
| 175 | + |
| 176 | + def cookie_string |
| 177 | + cookies.map { |k, v| "#{k}=#{v}" }.join('; ') |
113 | 178 | end |
114 | 179 |
|
115 | 180 | def store_cookies(response) |
116 | | - host = URI(response.env.url).host |
117 | | - cookie_header = response.headers['set-cookie'] |
118 | | - return unless cookie_header |
119 | | - |
120 | | - Array(cookie_header).each do |cookie_string| |
121 | | - cookie_string.split(', ').each do |cookie| |
122 | | - cookie_name = cookie.split('=').first |
123 | | - cookie_value = cookie.split(';').first |
124 | | - @cookies["#{host}_#{cookie_name}"] = cookie_value |
| 181 | + set_cookie = response.headers['set-cookie'] |
| 182 | + return unless set_cookie |
| 183 | + |
| 184 | + set_cookie |
| 185 | + .split(', ') |
| 186 | + .each do |cookie_header| |
| 187 | + name, value = cookie_header.split(';').first.split('=', 2) |
| 188 | + cookies[name] = value if name && value |
125 | 189 | end |
126 | | - end |
127 | 190 | end |
128 | 191 | end |
129 | 192 | end |
|
0 commit comments