-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Daniel Neighman
committed
Nov 16, 2015
0 parents
commit 81e6a3f
Showing
10 changed files
with
475 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/_build | ||
/cover | ||
/deps | ||
erl_crash.dump | ||
*.ez | ||
/doc |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
# ÜeberauthSlack | ||
|
||
Proivdes an Üeberauth strategy to use Slack as the authentication mechanism. | ||
|
||
## Installation | ||
|
||
If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: | ||
|
||
1. Add ueberauth\_slack to your list of dependencies in `mix.exs`: | ||
|
||
````elixir | ||
def deps do | ||
[{:ueberauth_slack, "~> 0.1.0"}] | ||
end | ||
```` | ||
|
||
2. Ensure oauth2 is started before your application: | ||
|
||
```elixir | ||
def application do | ||
[applications: [:oauth2]] | ||
end | ||
```` | ||
|
||
3. Head over to [slack and create an application](https://api.slack.com/applications). You can use | ||
http://localhost:4000/auth/slack/callback as the url for dev. | ||
|
||
4. Add slack to your configuration (Phoenix) | ||
|
||
````elixir | ||
# To your ueberauth providers list | ||
config :ueberauth, Ueberauth, | ||
providers: [ | ||
slack: { Ueberauth.Strategy.Slack, [] } | ||
] | ||
|
||
# To provide access to the slack secrets | ||
|
||
config :ueberauth, Ueberauth.Strategy.Slack.OAuth, | ||
client_id: System.get_env("SLACK_CLIENT_ID"), | ||
client_secret: System.get_env("SLACK_CLIENT_SECRET") | ||
```` | ||
|
||
5. If you haven't already, create a pipeline for your Üeberauth | ||
````elixir | ||
pipeline :ueberauth do | ||
Ueberauth.plug "/auth" | ||
end | ||
scope "/auth" do | ||
pipe_through [:browser, :ueberauth] | ||
# it does not matter which contorller for the request phase | ||
# We just need to trigger the pipeline | ||
get "/slack", PagesController, :index | ||
get "/:provider/callback", AuthController, :callback | ||
end | ||
```` | ||
6. Implement your callback action in your controller to deal with an `Ueberauth.Auth` or `Ueberauth.Failure` callback | ||
## Calling | ||
To run through slack, depending on the url you setup with `Ueberauth.plug/1` you | ||
can hit the url for the request phase. | ||
/auth/slack | ||
Or with options | ||
/auth/slack?scope=users:read | ||
By default the scope requested will be "users:read". This can be configured | ||
either explicitly when you call the request path by providing a scope in the | ||
query string, or by setting a default in your configuration. | ||
````elixir | ||
config :ueberauth, Ueberauth, | ||
providers: [ | ||
slack: { Ueberauth.Strategy.Slack, [ default_scope: "users:read,users:write" ] | ||
] | ||
```` | ||
# License | ||
The MIT License (MIT) | ||
Copyright (c) 2015 Daniel Neighman | ||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# This file is responsible for configuring your application | ||
# and its dependencies with the aid of the Mix.Config module. | ||
use Mix.Config | ||
|
||
# This configuration is loaded before any dependency and is restricted | ||
# to this project. If another project depends on this project, this | ||
# file won't be loaded nor affect the parent project. For this reason, | ||
# if you want to provide default values for your application for | ||
# 3rd-party users, it should be done in your "mix.exs" file. | ||
|
||
# You can configure for your application as: | ||
# | ||
# config :ueberauth_slack, key: :value | ||
# | ||
# And access this configuration in your application as: | ||
# | ||
# Application.get_env(:ueberauth_slack, :key) | ||
# | ||
# Or configure a 3rd-party app: | ||
# | ||
# config :logger, level: :info | ||
# | ||
|
||
# It is also possible to import configuration files, relative to this | ||
# directory. For example, you can emulate configuration per environment | ||
# by uncommenting the line below and defining dev.exs, test.exs and such. | ||
# Configuration from the imported file will override the ones defined | ||
# here (which is why it is important to import them last). | ||
# | ||
# import_config "#{Mix.env}.exs" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
defmodule Ueberauth.Strategy.Slack do | ||
@moduledoc """ | ||
Implements an ÜeberauthSlack strategy for authentication with slack.com. | ||
When configuring the strategy in the Üeberauth providers, you can specify some defaults. | ||
* `uid_field` - The field to use as the UID field. This can be any populated field in the info struct. Default `:email` | ||
* `default_scope` - The scope to request by default from slack (permissions). Default "users:read" | ||
* `oauth2_module` - The OAuth2 module to use. Default Ueberauth.Strategy.Slack.OAuth | ||
````elixir | ||
config :ueberauth, Ueberauth, | ||
providers: [ | ||
slack: { Ueberauth.Strategy.Slack, [uid_field: :nickname, default_scope: "users:read,users:write"] } | ||
] | ||
""" | ||
use Ueberauth.Strategy, uid_field: :email, | ||
default_scope: "users:read", | ||
oauth2_module: Ueberauth.Strategy.Slack.OAuth | ||
|
||
alias Ueberauth.Auth.Info | ||
alias Ueberauth.Auth.Credentials | ||
alias Ueberauth.Auth.Extra | ||
|
||
# When handling the request just redirect to Slack | ||
@doc false | ||
def handle_request!(conn) do | ||
scopes = conn.params["scope"] || option(conn, :default_scope) | ||
opts = [ scope: scopes ] | ||
if conn.params["state"], do: opts = Keyword.put(opts, :state, conn.params["state"]) | ||
callback_url = callback_url(conn) | ||
|
||
if String.ends_with?(callback_url, "?"), do: callback_url = String.slice(callback_url, 0..-2) | ||
|
||
opts = Keyword.put(opts, :redirect_uri, callback_url) | ||
module = option(conn, :oauth2_module) | ||
|
||
redirect!(conn, apply(module, :authorize_url!, [opts])) | ||
end | ||
|
||
# When handling the callback, if there was no errors we need to | ||
# make two calls. The first, to fetch the slack auth is so that we can get hold of | ||
# the user id so we can make a query to fetch the user info. | ||
# So that it is available later to build the auth struct, we put it in the private section of the conn. | ||
@doc false | ||
def handle_callback!(%Plug.Conn{ params: %{ "code" => code } } = conn) do | ||
module = option(conn, :oauth2_module) | ||
token = apply(module, :get_token!, [[code: code]]) | ||
|
||
if token.access_token == nil do | ||
set_errors!(conn, [error(token.other_params["error"], token.other_params["error_description"])]) | ||
else | ||
conn | ||
|> store_token(token) | ||
|> fetch_auth(token) | ||
|> fetch_user(token) | ||
end | ||
end | ||
|
||
# If we don't match code, then we have an issue | ||
@doc false | ||
def handle_callback!(conn) do | ||
set_errors!(conn, [error("missing_code", "No code received")]) | ||
end | ||
|
||
# We store the token for use later when fetching the slack auth and user and constructing the auth struct. | ||
@doc false | ||
defp store_token(conn, token) do | ||
put_private(conn, :slack_token, token) | ||
end | ||
|
||
# Remove the temporary storage in the conn for our data. Run after the auth struct has been built. | ||
@doc false | ||
def handle_cleanup!(conn) do | ||
conn | ||
|> put_private(:slack_auth, nil) | ||
|> put_private(:slack_user, nil) | ||
|> put_private(:slack_token, nil) | ||
end | ||
|
||
# The structure of the requests is such that it is difficult to provide cusomization for the uid field. | ||
# instead, we allow selecting any field from the info struct | ||
@doc false | ||
def uid(conn) do | ||
Map.get(info(conn), option(conn, :uid_field)) | ||
end | ||
|
||
@doc false | ||
def credentials(conn) do | ||
token = conn.private.slack_token | ||
auth = conn.private.slack_auth | ||
user = conn.private.slack_user | ||
|
||
scopes = (token.other_params["scope"] || "") | ||
|> String.split(",") | ||
|
||
%Credentials{ | ||
token: token.access_token, | ||
refresh_token: token.refresh_token, | ||
expires_at: token.expires_at, | ||
token_type: token.token_type, | ||
expires: !!token.expires_at, | ||
scopes: scopes, | ||
other: %{ | ||
user: auth["user"], | ||
user_id: auth["user_id"], | ||
team: auth["team"], | ||
team_id: auth["team_id"], | ||
team_url: auth["url"], | ||
has_2fa: user["has_2fa"], | ||
is_admin: user["is_admin"], | ||
is_owner: user["is_owner"], | ||
is_primary_owner: user["is_primary_owner"], | ||
is_restricted: user["is_restricted"], | ||
is_ultra_restricted: user["is_ultra_restricted"], | ||
} | ||
} | ||
end | ||
|
||
@doc false | ||
def info(conn) do | ||
user = conn.private.slack_user | ||
auth = conn.private.slack_auth | ||
image_urls = user["profile"] | ||
|> Map.keys | ||
|> Enum.filter(&(&1 =~ ~r/^image_/)) | ||
|> Enum.map(&({ &1, user["profile"][&1] })) | ||
|> Enum.into(%{}) | ||
|
||
%Info{ | ||
name: name_from_user(user), | ||
nickname: user["name"], | ||
email: user["profile"]["email"], | ||
image: user["profile"]["image_48"], | ||
urls: Map.merge( | ||
image_urls, | ||
%{ | ||
team_url: auth["url"], | ||
} | ||
) | ||
} | ||
end | ||
|
||
@doc false | ||
def extra(conn) do | ||
%Extra { | ||
raw_info: %{ | ||
auth: conn.private.slack_auth, | ||
token: conn.private.slack_token, | ||
user: conn.private.slack_user | ||
} | ||
} | ||
end | ||
|
||
# Before we can fetch the user, we first need to fetch the auth to find out what the user id is. | ||
defp fetch_auth(conn, token) do | ||
case OAuth2.AccessToken.post(token, "/auth.test", token: token.access_token) do | ||
{ :ok, %OAuth2.Response{status_code: 401, body: _body}} -> | ||
set_errors!(conn, [error("token", "unauthorized")]) | ||
{ :ok, %OAuth2.Response{status_code: status_code, body: auth} } when status_code in 200..399 -> | ||
if auth["ok"] do | ||
put_private(conn, :slack_auth, auth) | ||
else | ||
set_errors!(conn, [error(auth["error"], auth["error"])]) | ||
end | ||
{ :error, %OAuth2.Error{reason: reason} } -> | ||
set_errors!(conn, [error("OAuth2", reason)]) | ||
end | ||
end | ||
|
||
# If the call to fetch the auth fails, we're going to have failures already in place. | ||
# If this happens don't try and fetch the user and just let it fail. | ||
defp fetch_user(%Plug.Conn{ assigns: %{ ueberauth_failure: _fails }} = conn, _), do: conn | ||
|
||
# Given the auth and token we can now fetch the user. | ||
defp fetch_user(conn, token) do | ||
auth = conn.private.slack_auth | ||
|
||
case OAuth2.AccessToken.post(token, "/users.info", token: token.access_token, user: auth["user_id"]) do | ||
{ :ok, %OAuth2.Response{status_code: 401, body: _body}} -> | ||
set_errors!(conn, [error("token", "unauthorized")]) | ||
{ :ok, %OAuth2.Response{status_code: status_code, body: user} } when status_code in 200..399 -> | ||
if user["ok"] do | ||
put_private(conn, :slack_user, user["user"]) | ||
else | ||
set_errors!(conn, [error(user["error"], user["error"])]) | ||
end | ||
{ :error, %OAuth2.Error{reason: reason} } -> | ||
set_errors!(conn, [error("OAuth2", reason)]) | ||
end | ||
end | ||
|
||
# Fetch the name to use. We try to start with the most specific name avaialble and | ||
# fallback to the least. | ||
defp name_from_user(user) do | ||
[ | ||
user["profile"]["real_name_normalized"], | ||
user["profile"]["real_name"], | ||
user["real_name"], | ||
user["name"], | ||
] | ||
|> Enum.reject(&(&1 == "" || &1 == nil)) | ||
|> List.first | ||
end | ||
|
||
defp option(conn, key) do | ||
Dict.get(options(conn), key, Dict.get(default_options, key)) | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
defmodule Ueberauth.Strategy.Slack.OAuth do | ||
use OAuth2.Strategy | ||
|
||
@defaults [ | ||
strategy: __MODULE__, | ||
site: "https://slack.com/api", | ||
authorize_url: "https://slack.com/oauth/authorize", | ||
token_url: "https://slack.com/api/oauth.access" | ||
] | ||
|
||
def client(opts \\ []) do | ||
opts = Keyword.merge(@defaults, Application.get_env(:ueberauth, Ueberauth.Strategy.Slack.OAuth)) | ||
|> Keyword.merge(opts) | ||
|
||
OAuth2.Client.new(opts) | ||
end | ||
|
||
def authorize_url!(params \\ [], opts \\ []) do | ||
client(opts) | ||
|> OAuth2.Client.authorize_url!(params) | ||
end | ||
|
||
def get_token!(params \\ [], options \\ %{}) do | ||
headers = Dict.get(options, :headers, []) | ||
options = Dict.get(options, :options, []) | ||
client_options = Dict.get(options, :client_options, []) | ||
OAuth2.Client.get_token!(client(client_options), params, headers, options) | ||
end | ||
|
||
# Strategy Callbacks | ||
|
||
def authorize_url(client, params) do | ||
OAuth2.Strategy.AuthCode.authorize_url(client, params) | ||
end | ||
|
||
def get_token(client, params, headers) do | ||
client | ||
|> put_header("Accept", "application/json") | ||
|> OAuth2.Strategy.AuthCode.get_token(params, headers) | ||
end | ||
end |
Oops, something went wrong.