x509_auth.rb
| 1 |
# -------------------------------------------------------------------------- #
|
|---|---|
| 2 |
# Copyright 2002-2012, OpenNebula Project Leads (OpenNebula.org) #
|
| 3 |
# #
|
| 4 |
# Licensed under the Apache License, Version 2.0 (the "License"); you may #
|
| 5 |
# not use this file except in compliance with the License. You may obtain #
|
| 6 |
# a copy of the License at #
|
| 7 |
# #
|
| 8 |
# http://www.apache.org/licenses/LICENSE-2.0 #
|
| 9 |
# #
|
| 10 |
# Unless required by applicable law or agreed to in writing, software #
|
| 11 |
# distributed under the License is distributed on an "AS IS" BASIS, #
|
| 12 |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
|
| 13 |
# See the License for the specific language governing permissions and #
|
| 14 |
# limitations under the License. #
|
| 15 |
#--------------------------------------------------------------------------- #
|
| 16 |
|
| 17 |
require 'openssl'
|
| 18 |
require 'base64'
|
| 19 |
require 'fileutils'
|
| 20 |
require 'yaml'
|
| 21 |
|
| 22 |
# X509 authentication class. It can be used as a driver for auth_mad
|
| 23 |
# as auth method is defined. It also holds some helper methods to be used
|
| 24 |
# by oneauth command
|
| 25 |
class X509Auth |
| 26 |
###########################################################################
|
| 27 |
#Constants with paths to relevant files and defaults
|
| 28 |
###########################################################################
|
| 29 |
if !ENV["ONE_LOCATION"] |
| 30 |
ETC_LOCATION = "/etc/one" |
| 31 |
else
|
| 32 |
ETC_LOCATION = ENV["ONE_LOCATION"] + "/etc" |
| 33 |
end
|
| 34 |
|
| 35 |
LOGIN_PATH = ENV['HOME']+'/.one/one_x509' |
| 36 |
|
| 37 |
X509_AUTH_CONF_PATH = ETC_LOCATION + "/auth/x509_auth.conf" |
| 38 |
|
| 39 |
X509_DEFAULTS = {
|
| 40 |
:ca_dir => ETC_LOCATION + "/auth/certificates" |
| 41 |
} |
| 42 |
|
| 43 |
###########################################################################
|
| 44 |
# Initialize x509Auth object
|
| 45 |
#
|
| 46 |
# @param [Hash] default options for path
|
| 47 |
# @option options [String] :certs_pem
|
| 48 |
# cert chain array in colon-separated pem format
|
| 49 |
# @option options [String] :key_pem
|
| 50 |
# key in pem format
|
| 51 |
# @option options [String] :ca_dir
|
| 52 |
# directory of trusted CA's. Needed for auth method, not for login.
|
| 53 |
def initialize(options={}) |
| 54 |
@options ||= X509_DEFAULTS |
| 55 |
@options.merge!(options)
|
| 56 |
|
| 57 |
load_options(X509_AUTH_CONF_PATH)
|
| 58 |
|
| 59 |
@cert_chain = @options[:certs_pem].collect do |cert_pem| |
| 60 |
OpenSSL::X509::Certificate.new(cert_pem) |
| 61 |
end
|
| 62 |
|
| 63 |
if @options[:key_pem] |
| 64 |
@key = OpenSSL::PKey::RSA.new(@options[:key_pem]) |
| 65 |
end
|
| 66 |
end
|
| 67 |
|
| 68 |
###########################################################################
|
| 69 |
# Client side
|
| 70 |
###########################################################################
|
| 71 |
|
| 72 |
# Creates the login file for x509 authentication at ~/.one/one_x509.
|
| 73 |
# By default it is valid as long as the certificate is valid. It can
|
| 74 |
# be changed to any number of seconds with expire parameter (sec.)
|
| 75 |
def login(user, expire=0) |
| 76 |
write_login(login_token(user,expire)) |
| 77 |
end
|
| 78 |
|
| 79 |
# Returns a valid password string to create a user using this auth driver.
|
| 80 |
# In this case the dn of the user certificate.
|
| 81 |
def password |
| 82 |
@cert_chain[0].subject.to_s.delete("\s") |
| 83 |
end
|
| 84 |
|
| 85 |
# Generates a login token in the form:
|
| 86 |
# user_name:x509:user_name:time_expires:cert_chain
|
| 87 |
# - user_name:time_expires is encrypted with the user certificate
|
| 88 |
# - user_name:time_expires:cert_chain is base64 encoded
|
| 89 |
def login_token(user, expire) |
| 90 |
if expire != 0 |
| 91 |
expires = Time.now.to_i + expire.to_i
|
| 92 |
else
|
| 93 |
expires = @cert_chain[0].not_after.to_i |
| 94 |
end
|
| 95 |
|
| 96 |
text_to_sign = "#{user}:#{expires}"
|
| 97 |
signed_text = encrypt(text_to_sign) |
| 98 |
|
| 99 |
certs_pem = @cert_chain.collect{|cert| cert.to_pem}.join(":") |
| 100 |
|
| 101 |
token = "#{signed_text}:#{certs_pem}"
|
| 102 |
token64 = Base64::encode64(token).strip.delete("\n") |
| 103 |
|
| 104 |
login_out = "#{user}:#{token64}"
|
| 105 |
|
| 106 |
login_out |
| 107 |
end
|
| 108 |
|
| 109 |
###########################################################################
|
| 110 |
# Server side
|
| 111 |
###########################################################################
|
| 112 |
# auth method for auth_mad
|
| 113 |
def authenticate(user, pass, signed_text) |
| 114 |
begin
|
| 115 |
# Decryption demonstrates that the user posessed the private key.
|
| 116 |
_user, expires = decrypt(signed_text).split(':')
|
| 117 |
|
| 118 |
return "User name missmatch" if user != _user |
| 119 |
|
| 120 |
return "x509 proxy expired" if Time.now.to_i >= expires.to_i |
| 121 |
|
| 122 |
# Some DN in the chain must match a DN in the password
|
| 123 |
dn_ok = @cert_chain.each do |cert| |
| 124 |
if pass.split('|').include?(cert.subject.to_s.delete("\s")) |
| 125 |
break true |
| 126 |
end
|
| 127 |
end
|
| 128 |
|
| 129 |
unless dn_ok == true |
| 130 |
return "Certificate subject missmatch" |
| 131 |
end
|
| 132 |
|
| 133 |
validate |
| 134 |
|
| 135 |
return true |
| 136 |
rescue => e
|
| 137 |
return e.message
|
| 138 |
end
|
| 139 |
end
|
| 140 |
|
| 141 |
private |
| 142 |
# Writes a login_txt to the login file as defined in LOGIN_PATH
|
| 143 |
# constant
|
| 144 |
def write_login(login_txt) |
| 145 |
# Inits login file path and creates ~/.one directory if needed
|
| 146 |
# Set instance variables
|
| 147 |
login_dir = File.dirname(LOGIN_PATH) |
| 148 |
|
| 149 |
begin
|
| 150 |
FileUtils.mkdir_p(login_dir)
|
| 151 |
rescue Errno::EEXIST |
| 152 |
end
|
| 153 |
|
| 154 |
file = File.open(LOGIN_PATH, "w") |
| 155 |
file.write(login_txt) |
| 156 |
file.close |
| 157 |
|
| 158 |
File.chmod(0600,LOGIN_PATH) |
| 159 |
end
|
| 160 |
|
| 161 |
# Load class options form a configuration file (yaml syntax)
|
| 162 |
def load_options(conf_file) |
| 163 |
if File.readable?(conf_file) |
| 164 |
conf_txt = File.read(conf_file)
|
| 165 |
conf_opt = YAML::load(conf_txt)
|
| 166 |
|
| 167 |
@options.merge!(conf_opt) if conf_opt != false |
| 168 |
end
|
| 169 |
end
|
| 170 |
|
| 171 |
###########################################################################
|
| 172 |
# Methods to encrpyt/decrypt keys
|
| 173 |
###########################################################################
|
| 174 |
# Encrypts data with the private key of the user and returns
|
| 175 |
# base 64 encoded output in a single line
|
| 176 |
def encrypt(data) |
| 177 |
return nil if !@key |
| 178 |
Base64::encode64(@key.private_encrypt(data)).delete("\n").strip |
| 179 |
end
|
| 180 |
|
| 181 |
# Decrypts base 64 encoded data with pub_key (public key)
|
| 182 |
def decrypt(data) |
| 183 |
@cert_chain[0].public_key.public_decrypt(Base64::decode64(data)) |
| 184 |
end
|
| 185 |
|
| 186 |
###########################################################################
|
| 187 |
# Validate the user certificate
|
| 188 |
###########################################################################
|
| 189 |
def validate |
| 190 |
now = Time.now
|
| 191 |
failed = "Could not validate user credentials: "
|
| 192 |
|
| 193 |
# Check start time and end time of certificates
|
| 194 |
@cert_chain.each do |cert| |
| 195 |
if cert.not_before > now || cert.not_after < now
|
| 196 |
raise failed + "Certificate not valid. Current time is " +
|
| 197 |
now.localtime.to_s + "."
|
| 198 |
end
|
| 199 |
end
|
| 200 |
|
| 201 |
begin
|
| 202 |
# Validate the proxy certifcates
|
| 203 |
signee = @cert_chain[0] |
| 204 |
|
| 205 |
## FC-Start
|
| 206 |
fc_ca_hash = signee.issuer.hash.to_s(16)
|
| 207 |
fc_ca_path = @options[:ca_dir] + '/' + fc_ca_hash + '.0' |
| 208 |
fc_ca_cert = OpenSSL::X509::Certificate.new( File.read(fc_ca_path) ) |
| 209 |
fc_rl_path = @options[:ca_dir] + '/' + fc_ca_hash + '.r0' |
| 210 |
fc_rl_cert = OpenSSL::X509::CRL.new( File.read(fc_rl_path) ) |
| 211 |
|
| 212 |
## First verify the CRL itself with its signer(DOEGrids.pem)
|
| 213 |
unless fc_rl_cert.verify( fc_ca_cert.public_key ) then |
| 214 |
raise failed + "CRL is not verified by its Signer"
|
| 215 |
end
|
| 216 |
|
| 217 |
fcarray = fc_rl_cert.revoked ## Extract the list of revoked certificates from the CRL
|
| 218 |
|
| 219 |
## Loop over the list and compare with the target personal certificate
|
| 220 |
fcarray.each do |e|
|
| 221 |
if e.serial.eql?(signee.serial) then # if e.serial == hkcert.serial then |
| 222 |
# puts "A = e.serial and B = signee.serial"
|
| 223 |
raise failed + "#{signee.subject.to_s} is found in the CRL, i.e. it is revoked"
|
| 224 |
end
|
| 225 |
end
|
| 226 |
## FC-End
|
| 227 |
|
| 228 |
@cert_chain[1..-1].each do |cert| |
| 229 |
if !((signee.issuer.to_s == cert.subject.to_s) &&
|
| 230 |
(signee.verify(cert.public_key))) |
| 231 |
raise failed + signee.subject.to_s + " with issuer " +
|
| 232 |
signee.issuer.to_s + " was not verified by " +
|
| 233 |
cert.subject.to_s + "."
|
| 234 |
end
|
| 235 |
signee = cert |
| 236 |
end
|
| 237 |
|
| 238 |
# Validate the End Entity certificate
|
| 239 |
if !@options[:ca_dir] |
| 240 |
raise failed + "No certifcate authority directory was specified."
|
| 241 |
end
|
| 242 |
|
| 243 |
begin
|
| 244 |
ca_hash = signee.issuer.hash.to_s(16)
|
| 245 |
ca_path = @options[:ca_dir] + '/' + ca_hash + '.0' |
| 246 |
|
| 247 |
ca_cert = OpenSSL::X509::Certificate.new(File.read(ca_path)) |
| 248 |
|
| 249 |
if !((signee.issuer.to_s == ca_cert.subject.to_s) &&
|
| 250 |
(signee.verify(ca_cert.public_key))) |
| 251 |
raise failed + signee.subject.to_s + " with issuer " +
|
| 252 |
signee.issuer.to_s + " was not verified by " +
|
| 253 |
ca_cert.subject.to_s + "."
|
| 254 |
end
|
| 255 |
|
| 256 |
signee = ca_cert |
| 257 |
end while ca_cert.subject.to_s != ca_cert.issuer.to_s |
| 258 |
rescue
|
| 259 |
raise |
| 260 |
end
|
| 261 |
end
|
| 262 |
end
|