# A personal OpenID identity provider, authentication is done by editing
# a YAML file on the server where this application runs
# (~/.local-openid/config.yml by default) instead of via HTTP/HTTPS
# form authentication in the browser.
#:stopdoc:
require 'tempfile'
require 'time'
require 'yaml'
require 'sinatra/base'
require 'openid'
require 'openid/extensions/sreg'
require 'openid/extensions/pape'
require 'openid/store/filesystem'
class LocalOpenID < Sinatra::Base
set :static, false
set :sessions, true
set :environment, :production
set :logging, false # load Rack::CommonLogger in config.ru instead
@@dir ||= File.expand_path(ENV['LOCAL_OPENID_DIR'] || '~/.local-openid')
Dir.mkdir(@@dir) unless File.directory?(@@dir)
# all the sinatra endpoints:
get('/xrds') { big_lock { render_identity_xrds(true) } }
get('/provider/xrds') { big_lock { render_provider_xrds(true) } }
get('/provider') { big_lock { get_or_post_provider } }
post('/provider') { big_lock { get_or_post_provider } }
get('/') { big_lock { render_identity_xrds } }
post('/') { big_lock { render_identity_xrds } }
private
# yes, I use gsub for templating because I find it easier than erb :P
PROMPT = %q!
OpenID login: %s
reload this page when approved: %s
!
PROVIDER_XRDS_HTML = %q!
OpenID server endpoint
OpenID server endpoint!
IDENTITY_XRDS_HTML = %q!
OpenID identity
OpenID identity!
PROVIDER_XRDS_XML = %q!
%types
%sprovider!
IDENTITY_XRDS_XML = %q!
%types
%sprovider%s%s!
CONFIG_HEADER = %!
This file may be changed by #{__FILE__} or your favorite $EDITOR
comments will be deleted when modified by #{__FILE__}. See the
comments end of this file for help on the format.
!.lstrip!
CONFIG_TRAILER = %!
Configuration file description.
* allowed_ips An array of strings representing IPs that may
authenticate through local-openid. Only put
IP addresses that you trust in here.
Each OpenID consumer trust root will have its own hash keyed by
the trust root URL. Keys in this hash are:
- expires The time at which this login will expire.
This is generally the only entry you need to edit
to approve a site. You may also delete this line
and rename the "expires1m" to this.
- expires1m The time 1 minute from when this entry was updated.
This is provided as a convenience for replacing
the default "expires" entry. This key may be safely
removed by a user editing it.
- updated Time this entry was updated, strictly informational.
- session_id Unique identifier in your session cookie to prevent
other users from hijacking your session. You may
delete this if you have changed browsers or computers.
- assoc_handle See the OpenID specs, may be empty. Do not edit this.
SReg keys supported by the Ruby OpenID implementation should be
supported, they include (but are not limited to):
! << OpenID::SReg::DATA_FIELDS.map do |key, value|
" - #{key}: #{value}"
end.join("\n") << %!
SReg keys may be global at the top-level or private to each trust root.
Per-trust root SReg entries override the global settings.
!
include OpenID::Server
# this is the heart of our provider logic, adapted from the
# Ruby OpenID gem Rails example
def get_or_post_provider
oidreq = begin
server.decode_request(params)
rescue ProtocolError => err
halt(500, err.to_s)
end
oidreq or return render_provider_xrds
oidresp = case oidreq
when CheckIDRequest
if oidreq.id_select && oidreq.immediate
oidreq.answer(false)
elsif is_authorized?(oidreq)
resp = oidreq.answer(true, nil, server_root)
add_sreg(oidreq, resp)
add_pape(oidreq, resp)
resp
elsif oidreq.immediate
oidreq.answer(false, server_root + "provider")
else
session[:id] ||= "#{Time.now.to_i}.#$$.#{rand}"
session[:ip] = request.ip
merge_config(oidreq)
write_config
# here we allow our user to open $EDITOR and edit the appropriate
# 'expires' field in config.yml corresponding to oidreq.trust_root
return PROMPT.gsub(/%s/, oidreq.trust_root)
end
else
server.handle_request(oidreq)
end
finalize_response(oidresp)
end
# we're the provider for exactly one identity. However, we do rely on
# being proxied and being hit with an appropriate HTTP Host: header.
# Don't expect OpenID consumers to handle port != 80.
def server_root
"http://#{request.host}/"
end
def server
@server ||= Server.new(
OpenID::Store::Filesystem.new("#@@dir/store"),
server_root + "provider")
end
# support the simple registration extension if possible,
# allow per-site overrides of various data points
def add_sreg(oidreq, oidresp)
sregreq = OpenID::SReg::Request.from_openid_request(oidreq) or return
per_site = config[oidreq.trust_root] || {}
sreg_data = {}
sregreq.all_requested_fields.each do |field|
sreg_data[field] = per_site[field] || config[field]
end
sregresp = OpenID::SReg::Response.extract_response(sregreq, sreg_data)
oidresp.add_extension(sregresp)
end
def add_pape(oidreq, oidresp)
papereq = OpenID::PAPE::Request.from_openid_request(oidreq) or return
paperesp = OpenID::PAPE::Response.new(papereq.preferred_auth_policies,
papereq.max_auth_age)
# since this implementation requires shell/filesystem access to the
# OpenID server to authenticate, we can say we're at the highest
# auth level possible...
paperesp.add_policy_uri(OpenID::PAPE::AUTH_MULTI_FACTOR_PHYSICAL)
paperesp.auth_time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
paperesp.nist_auth_level = 4
oidresp.add_extension(paperesp)
end
def err(msg)
env['rack.errors'].write("#{msg}\n")
false
end
def finalize_response(oidresp)
server.signatory.sign(oidresp) if oidresp.needs_signing
web_response = server.encode_response(oidresp)
case web_response.code
when HTTP_OK
web_response.body
when HTTP_REDIRECT
location = web_response.headers['location']
err("redirecting to: #{location} ...")
redirect(location)
else
halt(500, web_response.body)
end
end
# the heart of our custom authentication logic
def is_authorized?(oidreq)
(config['allowed_ips'] ||= []).include?(request.ip) or
return err("Not allowed: #{request.ip}\n" \
"You need to put this IP in the 'allowed_ips' array "\
"in:\n #@@dir/config.yml")
request.ip == session[:ip] or
return err("session IP mismatch: " \
"#{request.ip.inspect} != #{session[:ip].inspect}")
trust_root = oidreq.trust_root
per_site = config[trust_root] or
return err("trust_root unknown: #{trust_root}")
session_id = session[:id] or return err("no session ID")
assoc_handle = per_site['assoc_handle'] # this may be nil
expires = per_site['expires'] or
return err("no expires (trust_root=#{trust_root})")
assoc_handle == oidreq.assoc_handle or
return err("assoc_handle mismatch: " \
"#{assoc_handle.inspect} != #{oidreq.assoc_handle.inspect}" \
" (trust_root=#{trust_root})")
per_site['session_id'] == session_id or
return err("session ID mismatch: " \
"#{per_site['session_id'].inspect} != #{session_id.inspect}" \
" (trust_root=#{trust_root})")
expires > Time.now or
return err("Expired: #{expires.inspect} (trust_root=#{trust_root})")
true
end
def config
@config ||= begin
YAML.load(File.read("#@@dir/config.yml"))
rescue Errno::ENOENT
{}
end
end
def merge_config(oidreq)
per_site = config[oidreq.trust_root] ||= {}
per_site.merge!({
'assoc_handle' => oidreq.assoc_handle,
'expires' => Time.at(0).utc,
'updated' => Time.now.utc,
'expires1m' => Time.now.utc + 60, # easy edit to "expires" in $EDITOR
'session_id' => session[:id],
})
end
def write_config
path = "#@@dir/config.yml"
tmp = Tempfile.new('config.yml', File.dirname(path))
tmp.syswrite(CONFIG_HEADER.gsub(/^/m, "# "))
tmp.syswrite(config.to_yaml)
tmp.syswrite(CONFIG_TRAILER.gsub(/^/m, "# "))
tmp.fsync
File.rename(tmp.path, path)
tmp.close!
end
# this output is designed to be parsed by OpenID consumers
def render_provider_xrds(force = false)
if force || request.accept.include?('application/xrds+xml')
# this seems to work...
types = [ OpenID::OPENID_IDP_2_0_TYPE ]
headers['Content-Type'] = 'application/xrds+xml'
types = types.map { |uri| "#{uri}" }.join("\n")
PROVIDER_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
else # render a browser-friendly page with an XRDS pointer
headers['X-XRDS-Location'] = "#{server_root}provider/xrds"
PROVIDER_XRDS_HTML.gsub(/%s/, server_root)
end
end
def render_identity_xrds(force = false)
if force || request.accept.include?('application/xrds+xml')
# this seems to work...
types = [ OpenID::OPENID_2_0_TYPE,
OpenID::OPENID_1_0_TYPE,
OpenID::SREG_URI ]
headers['Content-Type'] = 'application/xrds+xml'
types = types.map { |uri| "#{uri}" }.join("\n")
IDENTITY_XRDS_XML.gsub(/%s/, server_root).gsub!(/%types/, types)
else # render a browser-friendly page with an XRDS pointer
headers['X-XRDS-Location'] = "#{server_root}xrds"
IDENTITY_XRDS_HTML.gsub(/%s/, server_root)
end
end
# if a single-user OpenID provider like us is being hit by multiple
# clients at once, then something is seriously wrong. Can't use
# Mutexes here since somebody could be running this as a CGI script
def big_lock(&block)
lock = "#@@dir/lock"
File.open(lock, File::WRONLY|File::CREAT|File::EXCL, 0600) do |fp|
begin
yield
ensure
File.unlink(lock)
end
end
rescue Errno::EEXIST
err("Lock: #{lock} exists! Possible hijacking attempt") rescue nil
end
end
#:startdoc: