Skip to content

Commit

Permalink
Add VK provider
Browse files Browse the repository at this point in the history
  • Loading branch information
msa7 committed Jun 3, 2017
1 parent f467b82 commit 79b8c83
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 8 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions spec/providers/vk_spec.cr
Original file line number Diff line number Diff line change
@@ -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" : "[email protected]"
})
)

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
1 change: 1 addition & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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")
70 changes: 70 additions & 0 deletions src/crystal_std_patch/access_token.cr
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions src/crystal_std_patch/access_token_mac.cr
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/multi_auth.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "oauth2"
require "./crystal_std_patch/**"
require "./multi_auth/**"

module MultiAuth
Expand Down
6 changes: 4 additions & 2 deletions src/multi_auth/engine.cr
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
4 changes: 0 additions & 4 deletions src/multi_auth/providers/facebook.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions src/multi_auth/providers/vk.cr
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 79b8c83

Please sign in to comment.