Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions openc3-cosmos-cmd-tlm-api/app/controllers/auth_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ def verify_service
end
end

def get_otp
user = authorization('system')
return unless user
render :plain => OpenC3::Authorization.generate_otp(user)
end

def set
if user_rate_limited?
head :too_many_requests
Expand Down
1 change: 1 addition & 0 deletions openc3-cosmos-cmd-tlm-api/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@
post "/auth/verify" => "auth#verify"
post "/auth/verify_service" => "auth#verify_service"
post "/auth/set" => "auth#set"
get "/auth/otp" => "auth#get_otp"

get "/internal/health" => "internal_health#health"
get "/internal/metrics" => "internal_metrics#index"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@
# See LICENSE.md for more details.

# Modified by OpenC3, Inc.
# All changes Copyright 2022, OpenC3, Inc.
# All changes Copyright 2026, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
*/

import { Api } from '.'
import { createConsumer } from '@anycable/web'

export default class Cable {
constructor(url = '/openc3-api/cable') {
constructor(url = '/openc3-api/cable', otpUrl = '/openc3-api/auth/otp') {
this._cable = null
this._url = url
this._otpUrl = otpUrl
}
disconnect() {
if (this._cable) {
Expand All @@ -29,20 +31,23 @@ export default class Cable {
}
}
createSubscription(channel, scope, callbacks = {}, additionalOptions = {}) {
return OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity).then(
(refreshed) => {
return OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity)
.then((refreshed) => {
if (refreshed) {
OpenC3Auth.setTokens()
}
return Api.get(this._otpUrl, {
params: {
scope,
},
})
})
.then(({ data: otp }) => {
if (this._cable == null) {
let final_url =
this._url +
'?scope=' +
encodeURIComponent(scope) +
'&authorization=' +
encodeURIComponent(localStorage.openc3Token)
final_url = new URL(final_url, document.baseURI).href
this._cable = createConsumer(final_url)
const finalUrl = new URL(this._url, document.baseURI)
finalUrl.searchParams.set('scope', scope)
finalUrl.searchParams.set('authorization', otp)
this._cable = createConsumer(finalUrl.href)
}
return this._cable.subscriptions.create(
{
Expand All @@ -51,8 +56,7 @@ export default class Cable {
},
callbacks,
)
},
)
})
}
recordPing() {
// Noop with Anycable
Expand Down
33 changes: 30 additions & 3 deletions openc3/lib/openc3/models/auth_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class AuthModel

MIN_PASSWORD_LENGTH = 8

SESSION_PREFIX = "ses_"
OTP_PREFIX = "otp_"

def self.set?(key = PRIMARY_KEY)
Store.exists(key) == 1
end
Expand Down Expand Up @@ -75,12 +78,18 @@ def self.verify_no_service(token, mode: :token)
# Check cached session tokens and password hash
time = Time.now
unless mode == :password
return true if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
terminate_otp(token)
return true
end

# Check stored session tokens
@@session_cache = Store.hgetall(SESSIONS_KEY)
@@session_cache_time = time
return true if @@session_cache[token]
if @@session_cache[token]
terminate_otp(token)
return true
end
end

unless mode == :token
Expand Down Expand Up @@ -113,8 +122,15 @@ def self.set(password, old_password, key = PRIMARY_KEY)
end

# Creates a new session token. DO NOT CALL BEFORE VERIFYING.
def self.generate_session
# @param otp [Boolean] whether to create a one-time use token (default: false)
# @return [String] the new session token
def self.generate_session(otp: false)
token = SecureRandom.urlsafe_base64(nil, false)
if otp
token = OTP_PREFIX + token
else
token = SESSION_PREFIX + token
end
Store.hset(SESSIONS_KEY, token, Time.now.iso8601)
return token
end
Expand All @@ -125,5 +141,16 @@ def self.logout
@@session_cache = nil
@@session_cache_time = nil
end

# Terminates the given session token.
def self.terminate(token)
Store.hdel(SESSIONS_KEY, token)
@@session_cache.delete(token) if @@session_cache
end

# Terminates the session if the token is an OTP.
def self.terminate_otp(token)
terminate(token) if token.start_with?(OTP_PREFIX)
end
end
end
2 changes: 1 addition & 1 deletion openc3/lib/openc3/script/web_socket_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def write(data)
# Connect to the websocket with authorization in query params
def connect
disconnect()
final_url = @url + "?scope=#{@scope}&authorization=#{@authentication.token(include_bearer: false)}"
final_url = @url + "?scope=#{@scope}&authorization=#{@authentication.get_otp(scope: @scope)}"
@stream = WebSocketClientStream.new(final_url, @write_timeout, @read_timeout, @connect_timeout)
@stream.headers = {
'Sec-WebSocket-Protocol' => 'actioncable-v1-json, actioncable-unsupported',
Expand Down
59 changes: 43 additions & 16 deletions openc3/lib/openc3/utilities/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,10 @@ def initialize()
raise OpenC3AuthenticationError, "Authentication requires environment variable OPENC3_API_PASSWORD"
end
@service = password == ENV['OPENC3_SERVICE_PASSWORD']
retries = 0
begin
retry_faraday_request do
response = _make_auth_request(password)
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
retries += 1
if retries <= 3
STDOUT.puts "Authentication request failed (attempt #{retries}/3): #{e.message}. Retrying in #{retries}s..."
sleep(retries)
retry
end
raise
@token = response.body
end
@token = response.body
if @token.nil? or @token.empty?
raise OpenC3AuthenticationError, "Authentication failed. Please check the password in the environment variable OPENC3_API_PASSWORD"
end
Expand All @@ -56,23 +47,59 @@ def token(include_bearer: true)
@token
end

def get_otp(scope: 'DEFAULT')
if @token.nil? or @token.empty?
raise OpenC3AuthenticationError, "Uninitialized authentication: unable to get OTP"
end
retry_faraday_request do
response = _make_otp_request(scope: scope)
return response.body
end
end

def _make_auth_request(password)
Faraday.new.post(_generate_auth_url, '{"password": "' + password + '"}', {'Content-Type' => 'application/json'})
end

def _generate_auth_url
def _make_otp_request(scope: 'DEFAULT')
params = {
'scope' => scope
}
headers = {
'Authorization' => token,
}
Faraday.new.get(_generate_auth_url('/auth/otp'), params, headers)
end

def _generate_auth_url(endpoint = nil)
schema = ENV['OPENC3_API_SCHEMA'] || 'http'
hostname = ENV['OPENC3_API_HOSTNAME'] || (ENV['OPENC3_DEVEL'] ? '127.0.0.1' : 'openc3-cosmos-cmd-tlm-api')
port = ENV['OPENC3_API_PORT'] || '2901'
port = port.to_i
endpoint = if @service
"auth/verify_service"
else
"auth/verify"
unless endpoint
endpoint = if @service
"auth/verify_service"
else
"auth/verify"
end
end
return "#{schema}://#{hostname}:#{port}/openc3-api/#{endpoint}"
end

def retry_faraday_request(max_retries: 3)
retries = 0
begin
yield
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
retries += 1
if retries <= max_retries
STDOUT.puts "Authentication request failed (attempt #{retries}/3): #{e.message}. Retrying in #{retries}s..."
sleep(retries)
retry
end
raise
end
end
end

# OpenC3 enterprise Keycloak authentication code
Expand Down
9 changes: 8 additions & 1 deletion openc3/lib/openc3/utilities/authorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class ForbiddenError < StandardError
end

module Authorization
ANONYMOUS_USER = "anonymous"

def self.generate_otp(user)
raise AuthError.new("Invalid OTP user") unless user == ANONYMOUS_USER
return OpenC3::AuthModel.generate_session(otp: true)
end

private

# Raises an exception if unauthorized, otherwise does nothing
Expand All @@ -41,7 +48,7 @@ def authorize(permission: nil, target_name: nil, packet_name: nil, interface_nam
raise AuthError.new("Token is invalid")
end
end
return "anonymous"
return ANONYMOUS_USER
end

def user_info(_token)
Expand Down
2 changes: 1 addition & 1 deletion openc3/python/openc3/script/web_socket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def connect(self):
self.disconnect()
# Add the token directly in the URL since adding it to the header doesn't seem to work
# Note in the this case we remove the "Bearer " string which is part of the token
final_url = self.url + f"?scope={self.scope}&authorization={self.authentication.token(include_bearer=False)}"
final_url = self.url + f"?scope={self.scope}&authorization={self.authentication.get_otp(self.scope)}"
self.stream = WebSocketClientStream(final_url, self.write_timeout, self.read_timeout, self.connect_timeout)
self.stream.headers = {
"Sec-WebSocket-Protocol": "actioncable-v1-json, actioncable-unsupported",
Expand Down
15 changes: 13 additions & 2 deletions openc3/python/openc3/utilities/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,23 @@ def __init__(self):
def token(self, include_bearer=True):
return self._token

def _generate_auth_url(self):
def get_otp(self, scope="DEFAULT"):
if not self._token:
raise OpenC3AuthenticationError("Uninitialized authentication: unable to get OTP")
response = Session().get(
self._generate_auth_url("/auth/otp"),
params={"scope": scope},
headers={"Authorization": self.token()},
)
return response.text

def _generate_auth_url(self, endpoint=None):
schema = OPENC3_API_SCHEMA or "http"
hostname = OPENC3_API_HOSTNAME or ("127.0.0.1" if OPENC3_DEVEL else "openc3-cosmos-cmd-tlm-api")
port = OPENC3_API_PORT or "2901"
port = int(port)
endpoint = "auth/verify_service" if self.service else "auth/verify"
if endpoint is None:
endpoint = "auth/verify_service" if self.service else "auth/verify"
return f"{schema}://{hostname}:{port}/openc3-api/{endpoint}"


Expand Down
Loading
Loading