diff --git a/README.md b/README.md index 518a9bf..c8d6b60 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ MultiAuth is a library that standardizes multi-provider authentication for web applications. Currently supported providers: -- Github -- Facebook +- Github.com +- Facebook.com +- Vk.com ## Installation diff --git a/spec/providers/vk_spec.cr b/spec/providers/vk_spec.cr new file mode 100644 index 0000000..a067711 --- /dev/null +++ b/spec/providers/vk_spec.cr @@ -0,0 +1,45 @@ +require "../spec_helper" + +describe MultiAuth do + context "vk" do + it "generates authorize_uri" do + uri = MultiAuth.make("vk", "/callback").authorize_uri + uri.should eq("https://oauth.vk.com/authorize?client_id=vk_id&redirect_uri=%2Fcallback&response_type=code&scope=email") + end + + it "fetch user" do + WebMock.wrap do + WebMock + .stub(:post, "https://oauth.vk.com/access_token") + .with( + body: "client_id=vk_id&client_secret=vk_secret&redirect_uri=%2Fcallback&grant_type=authorization_code&code=123", + headers: {"Accept" => "application/json", "Content-Length" => "103", "Host" => "oauth.vk.com", "Content-type" => "application/x-www-form-urlencoded"}) + .to_return( + body: %({ + "access_token" : "1111", + "expires_in" : 899, + "refresh_token" : null, + "scope" : "email", + "user_id" : "3333", + "email" : "s@msa7.ru" + }) + ) + + WebMock + .stub(:get, %(https://api.vk.com/method/users.get?fields=about,photo_max_orig,city,country,domain,contacts,site&user_id="3333"&v=5.52)) + .to_return( + body: %({"response": [{ + "first_name" : "Sergey", + "last_name" : "Makridenkov", + "id" : 3333 + }]}) + ) + + user = MultiAuth.make("vk", "/callback").user({"code" => "123"}).as(MultiAuth::User) + + user.name.should eq("Makridenkov Sergey") + user.uid.should eq("3333") + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 74c4acf..42728e0 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -5,3 +5,4 @@ require "../src/multi_auth" MultiAuth.config("google", "google_id", "google_secret") MultiAuth.config("github", "github_id", "github_secret") MultiAuth.config("facebook", "facebook_id", "facebook_secret") +MultiAuth.config("vk", "vk_id", "vk_secret") diff --git a/src/crystal_std_patch/access_token.cr b/src/crystal_std_patch/access_token.cr new file mode 100644 index 0000000..8e24083 --- /dev/null +++ b/src/crystal_std_patch/access_token.cr @@ -0,0 +1,70 @@ +# TODO remove hack after release +# https://github.com/crystal-lang/crystal/commit/31099237c87a3851c8cb2a78df5ff00bac7364c6 + +# Base class for the two possible access tokens: Bearer and Mac. +# +# Use `#authenticate` to authenticate an `HTTP::Client`. +abstract class OAuth2::AccessToken + def self.new(pull : JSON::PullParser) + token_type = nil + access_token = nil + expires_in = nil + refresh_token = nil + scope = nil + mac_algorithm = nil + mac_key = nil + extra = nil + + pull.read_object do |key| + case key + when "token_type" then token_type = pull.read_string + when "access_token" then access_token = pull.read_string + when "expires_in" then expires_in = pull.read_int + when "refresh_token" then refresh_token = pull.read_string_or_null + when "scope" then scope = pull.read_string_or_null + when "mac_algorithm" then mac_algorithm = pull.read_string + when "mac_key" then mac_key = pull.read_string + else + extra ||= {} of String => String + extra[key] = pull.read_raw + end + end + + access_token = access_token.not_nil! + token_type ||= "bearer" + + case token_type.downcase + when "bearer" + Bearer.new(access_token, expires_in, refresh_token, scope, extra) + when "mac" + Mac.new(access_token, expires_in, mac_algorithm.not_nil!, mac_key.not_nil!, refresh_token, scope, Time.now.epoch, extra) + else + raise "Uknown token_type in access token json: #{token_type}" + end + end + + property access_token : String + property expires_in : Int64? + property refresh_token : String? + property scope : String? + + # JSON key-value pairs that are outside of the OAuth2 spec are + # stored in this property in case they are needed. Their value + # is the raw JSON string found in the JSON value (with possible + # changes in the string format, but preserving JSON semantic). + # For example if the value was `[1, 2, 3]` then the value in this hash + # will be the string "[1,2,3]". + property extra : Hash(String, String)? + + def initialize(@access_token : String, expires_in : Int?, @refresh_token : String? = nil, @scope : String? = nil, @extra = nil) + @expires_in = expires_in.try &.to_i64 + end + + abstract def authenticate(request : HTTP::Request, tls) + + def authenticate(client : HTTP::Client) + client.before_request do |request| + authenticate request, client.tls? + end + end +end diff --git a/src/crystal_std_patch/access_token_mac.cr b/src/crystal_std_patch/access_token_mac.cr new file mode 100644 index 0000000..998ac6c --- /dev/null +++ b/src/crystal_std_patch/access_token_mac.cr @@ -0,0 +1,76 @@ +# TODO remove hack after release +# https://github.com/crystal-lang/crystal/commit/31099237c87a3851c8cb2a78df5ff00bac7364c6 + +require "secure_random" +require "openssl/hmac" +require "base64" +require "./access_token" + +class OAuth2::AccessToken::Mac < OAuth2::AccessToken + def self.new(pull : JSON::PullParser) + OAuth2::AccessToken.new(pull).as(self) + end + + property mac_algorithm : String + property mac_key : String + property issued_at : Int64 + + def initialize(access_token, expires_in, @mac_algorithm, @mac_key, refresh_token = nil, scope = nil, @issued_at = Time.now.epoch, extra = nil) + super(access_token, expires_in, refresh_token, scope, extra) + end + + def token_type + "Mac" + end + + def authenticate(request : HTTP::Request, tls) + ts = Time.now.epoch + nonce = "#{ts - @issued_at}:#{SecureRandom.hex}" + method = request.method + uri = request.resource + host, port = host_and_port request, tls + ext = "" + + mac = Mac.signature ts, nonce, method, uri, host, port, ext, mac_algorithm, mac_key + + header = %(MAC id="#{access_token}", nonce="#{nonce}", ts="#{ts}", mac="#{mac}") + request.headers["Authorization"] = header + end + + def self.signature(ts, nonce, method, uri, host, port, ext, mac_algorithm, mac_key) + normalized_request_string = "#{ts}\n#{nonce}\n#{method}\n#{uri}\n#{host}\n#{port}\n#{ext}\n" + + digest = case mac_algorithm + when "hmac-sha-1" then :sha1 + when "hmac-sha-256" then :sha256 + else raise "Unsupported algorithm: #{mac_algorithm}" + end + Base64.strict_encode OpenSSL::HMAC.digest(digest, mac_key, normalized_request_string) + end + + def to_json(json : JSON::Builder) + json.object do + json.field "token_type", "mac" + json.field "access_token", access_token + json.field "expires_in", expires_in + json.field "refresh_token", refresh_token if refresh_token + json.field "scope", scope if scope + json.field "mac_algorithm", mac_algorithm + json.field "mac_key", mac_key + end + end + + def_equals_and_hash access_token, expires_in, mac_algorithm, mac_key, refresh_token, scope + + private def host_and_port(request, tls) + host_header = request.headers["Host"] + if colon_index = host_header.index ':' + host = host_header[0...colon_index] + port = host_header[colon_index + 1..-1].to_i + else + host = host_header + port = tls ? 443 : 80 + end + {host, port} + end +end diff --git a/src/multi_auth.cr b/src/multi_auth.cr index 3894c0e..12f9a91 100644 --- a/src/multi_auth.cr +++ b/src/multi_auth.cr @@ -1,4 +1,5 @@ require "oauth2" +require "./crystal_std_patch/**" require "./multi_auth/**" module MultiAuth diff --git a/src/multi_auth/engine.cr b/src/multi_auth/engine.cr index 54d5165..c2173c1 100644 --- a/src/multi_auth/engine.cr +++ b/src/multi_auth/engine.cr @@ -1,10 +1,12 @@ class MultiAuth::Engine def initialize(provider : String, redirect_uri : String) provider_class = case provider - # when "google" then Provider::Google + # when "google" then Provider::Google when "github" then Provider::Github when "facebook" then Provider::Facebook - else raise "Provider #{provider} not implemented" + when "vk" then Provider::Vk + else + raise "Provider #{provider} not implemented" end client_id, client_secret = MultiAuth.configuration[provider] diff --git a/src/multi_auth/providers/facebook.cr b/src/multi_auth/providers/facebook.cr index b8d5fc4..21e5d3e 100644 --- a/src/multi_auth/providers/facebook.cr +++ b/src/multi_auth/providers/facebook.cr @@ -52,10 +52,6 @@ class MultiAuth::Provider::Facebook < MultiAuth::Provider fb_user end - private def api(access_token) - api - end - private def client OAuth2::Client.new( "www.facebook.com", diff --git a/src/multi_auth/providers/vk.cr b/src/multi_auth/providers/vk.cr new file mode 100644 index 0000000..57f4911 --- /dev/null +++ b/src/multi_auth/providers/vk.cr @@ -0,0 +1,100 @@ +class MultiAuth::Provider::Vk < MultiAuth::Provider + def authorize_uri(scope = nil) + @scope = scope || "email" + client.get_authorize_uri(@scope) + end + + def user(params : Hash(String, String)) + vk_user = fetch_vk_user(params["code"]) + + user = User.new("vk", vk_user.id, vk_user.name, vk_user.raw_json.not_nil!) + + user.email = vk_user.email + user.first_name = vk_user.first_name + user.last_name = vk_user.last_name + user.nickname = vk_user.domain + user.description = vk_user.about + user.image = vk_user.photo_max_orig + user.phone = vk_user.mobile_phone || vk_user.home_phone + user.access_token = vk_user.access_token + + location = [] of String + location << vk_user.city.not_nil!.title if vk_user.city + location << vk_user.country.not_nil!.title if vk_user.country + user.location = location.join(", ") unless location.empty? + + urls = {} of String => String + urls["web"] = vk_user.site.not_nil! if vk_user.site + user.urls = urls unless urls.empty? + + user + end + + class VkTitle + JSON.mapping( + title: String + ) + end + + class VkUser + property raw_json : String? + property access_token : OAuth2::AccessToken? + property email : String? + property id : String? + + def name + "#{last_name} #{first_name}" + end + + JSON.mapping( + id: {type: String, converter: String::RawConverter}, + last_name: String?, + first_name: String?, + site: String?, + city: VkTitle?, + country: VkTitle?, + domain: String?, + about: String?, + photo_max_orig: String?, + mobile_phone: String?, + home_phone: String? + ) + end + + class VkResponse + JSON.mapping( + response: Array(VkUser), + ) + end + + private def fetch_vk_user(code) + access_token = client.get_access_token_using_authorization_code(code) + + api = HTTP::Client.new("api.vk.com", tls: true) + access_token.authenticate(api) + + user_id = access_token.extra.not_nil!["user_id"] + user_email = access_token.extra.not_nil!["email"] + + fields = "about,photo_max_orig,city,country,domain,contacts,site" + raw_json = api.get("/method/users.get?fields=#{fields}&user_id=#{user_id}&v=5.52").body + + vk_user = VkResponse.from_json(raw_json).response.first + vk_user.email = user_email + vk_user.access_token = access_token + vk_user.raw_json = raw_json + + vk_user + end + + private def client + OAuth2::Client.new( + "oauth.vk.com", + client_id, + client_secret, + redirect_uri: redirect_uri, + authorize_uri: "/authorize", + token_uri: "/access_token" + ) + end +end