* Add field for client_secret in Oauth Client Doctype * openid_profile endpoint in oauth2.py * added stub methods for OpenID Connect in RequestValidator * [Fix] using werkzeug url_fix on uri * added 3 oidc methods in RequestValidator * Added Frappe Section in Social Login Keys Add section in `Social Login Keys` for fields `Frappe Client ID` and `Frappe Client Secret` and additional field Frappe OAuth 2 Server because github, facebook and google have fixed urls, Frappe server URL can change as per the hosting domain/server/company * [Fix] accept code id_token param for oidc * generate id_token jwt alg HS256 * Updates to OAuth 2 and OIDC `OAuth Authorization Code` and `OAuth Bearer Token` DocType made RO Delete Invalid Codes and Revoked Tokens periodically generate and send `id_token` only if scope of token is `openid` * [Fix] Periodically delete revoked tokens * Social Logins untested * Enabled Frappe social login * [Docs] OpenID Connect and Frappe social login * [Fix] Allow multiple scopes for OAuth 2 * [Docs] Added screenshot steps to configure openid and frappe social login * saved doctype to solve merge conflict * [fix] re-added client_secret after resolving merge conlict * [Fix] frappe_userid and default scopeversion-14
@@ -1712,6 +1712,34 @@ | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "frappe_userid", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Frappe User ID", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"precision": "", | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"remember_last_selected_value": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
} | |||
], | |||
"hide_heading": 0, | |||
@@ -1727,7 +1755,7 @@ | |||
"istable": 0, | |||
"max_attachments": 5, | |||
"menu_index": 0, | |||
"modified": "2016-11-07 05:27:32.432906", | |||
"modified": "2016-11-07 18:53:28.155954", | |||
"modified_by": "Administrator", | |||
"module": "Core", | |||
"name": "User", | |||
@@ -67,6 +67,9 @@ class User(Document): | |||
if self.language == "Loading...": | |||
self.language = None | |||
if (self.name not in ["Administrator", "Guest"]) and (not self.frappe_userid): | |||
self.frappe_userid = frappe.generate_hash(length=39) | |||
def on_update(self): | |||
# clear new password | |||
self.validate_user_limit() | |||
@@ -1,3 +1,4 @@ | |||
rest_api | |||
how_to_setup_oauth | |||
using_oauth | |||
openid_connect_and_frappe_social_login |
@@ -0,0 +1,72 @@ | |||
# OpenID Connect and Frappe social login | |||
## OpenID Connect | |||
Frappe also uses Open ID connect essential standard for authenticating users. To get `id_token` with `access_token`, pass `openid` as the value for the scope parameter during authorization request. | |||
If the scope is `openid` the JSON response with `access_token` will also include a JSON Web Token (`id_token`) signed with `HS256` and `Client Secret`. The decoded `id_token` includes the `at_hash`. | |||
Example Bearer Token with scope `openid` | |||
``` | |||
{ | |||
"token_type": "Bearer", | |||
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJpc3MiOiJodHRwczovL21udGVjaG5pcXVlLmNvbSIsImF0X2hhc2giOiJOQlFXbExJUy1lQ1BXd1d4Y0EwaVpnIiwiYXVkIjoiYjg3NzJhZWQ1YyIsImV4cCI6MTQ3Nzk1NTYzMywic3ViIjoiNWFjNDE2NThkZjFiZTE1MjI4M2QxYTk0YjhmYzcwNDIifQ.1GRvhk5wNoR4GWoeQfleEDgtLS5nvj9nsO4xd8QE-Uk", | |||
"access_token": "ZJD04ldyyvjuAngjgBrgHwxcOig4vW", | |||
"scope": "openid", | |||
"expires_in": 3600, | |||
"refresh_token": "2pBTDTGhjzs2EWRkcNV1N67yw0nizS" | |||
} | |||
``` | |||
## Frappe social login setup | |||
In this example there are 2 servers, | |||
### Primary Server | |||
This is the main server hosting all the users. e.g. `https://frappe.io`. To setup this as the main server, go to *Setup* > *Integrations* > *Social Login Keys* and enter `https://frappe.io` in the field `Frappe Server URL`. This URL repeats in all other Frappe servers who connect to this server to authenticate. Effectively, this is the main Identity Provider (IDP). | |||
Under this server add as many `OAuth Client`(s) as required. Because we are setting up one app server, add only one `OAuth Client` | |||
### Frappe App Server | |||
This is the client connecting to the IDP. Go to *Setup* > *Integrations* > *Social Login Keys* on this server and add appropriate values to `Frappe Client ID` and `Frappe Client Secret` (refer to client added in primary server). As mentioned before keep the `Frappe Server URL` as `https://frappe.io` | |||
Now you will see Frappe icon on the login page. Click on this icon to login with account created in primary server (IDP) `https://frappe.io` | |||
**Note**: If `Skip Authorization` is checked while registering a client, page to allow or deny the granting access to resource is not shown. This can be used if the apps are internal to one organization and seamless user experience is needed. | |||
## Steps | |||
### Part 1 : on Frappe Identity Provider (IDP) | |||
Login to IDP | |||
<img img class="screenshot" src="/assets/img/00-login-to-idp.png"> | |||
Add OAuth Client on IDP | |||
<img img class="screenshot" src="/assets/img/01-add-oauth-client-on-idp.png"> | |||
Set Server URL on IDP | |||
<img img class="screenshot" src="/assets/img/02-set-server-url-on-idp.png"> | |||
### Part 2 : on Frappe App Server | |||
Set `Frappe Client ID` and `Frappe Client Secret` on App server (refer the client set on IDP) | |||
<img img class="screenshot" src="/assets/img/03-set-clientid-client-secret-server-on-app-server.png"> | |||
**Note**: Frappe Server URL is the main server where identities from your organization are stored. | |||
Login Screen on App Server (login with frappe) | |||
<img img class="screenshot" src="/assets/img/04-login-screen-on-app-server.png"> | |||
### Part 3 : Redirected on IDP | |||
login with user on IDP | |||
<img img class="screenshot" src="/assets/img/05-login-with-user-on-idp.png"> | |||
Confirm Access on IDP | |||
<img img class="screenshot" src="/assets/img/06-confirm-grant-access-on-idp.png"> | |||
### Part 4 : Back on App Server | |||
Logged in on app server with ID from IDP | |||
<img img class="screenshot" src="/assets/img/07-logged-in-as-website-user-with-id-from-idp.png"> |
@@ -116,6 +116,7 @@ scheduler_events = { | |||
"frappe.email.queue.flush", | |||
"frappe.email.doctype.email_account.email_account.pull", | |||
"frappe.email.doctype.email_account.email_account.notify_unreplied", | |||
"frappe.oauth.delete_oauth2_data" | |||
], | |||
"hourly": [ | |||
"frappe.model.utils.link_count.update_link_count", | |||
@@ -217,7 +217,7 @@ | |||
"issingle": 0, | |||
"istable": 0, | |||
"max_attachments": 0, | |||
"modified": "2016-11-07 05:22:59.015955", | |||
"modified": "2016-11-07 18:31:54.470173", | |||
"modified_by": "Administrator", | |||
"module": "Integration Broker", | |||
"name": "OAuth Authorization Code", | |||
@@ -228,7 +228,7 @@ | |||
"amend": 0, | |||
"apply_user_permissions": 0, | |||
"cancel": 0, | |||
"create": 1, | |||
"create": 0, | |||
"delete": 1, | |||
"email": 1, | |||
"export": 1, | |||
@@ -243,7 +243,7 @@ | |||
"set_user_permissions": 0, | |||
"share": 1, | |||
"submit": 0, | |||
"write": 1 | |||
"write": 0 | |||
} | |||
], | |||
"quick_entry": 0, | |||
@@ -244,7 +244,7 @@ | |||
"issingle": 0, | |||
"istable": 0, | |||
"max_attachments": 0, | |||
"modified": "2016-11-07 05:54:52.149689", | |||
"modified": "2016-11-07 18:31:32.243853", | |||
"modified_by": "Administrator", | |||
"module": "Integration Broker", | |||
"name": "OAuth Bearer Token", | |||
@@ -255,7 +255,7 @@ | |||
"amend": 0, | |||
"apply_user_permissions": 0, | |||
"cancel": 0, | |||
"create": 1, | |||
"create": 0, | |||
"delete": 1, | |||
"email": 1, | |||
"export": 1, | |||
@@ -270,7 +270,7 @@ | |||
"set_user_permissions": 0, | |||
"share": 1, | |||
"submit": 0, | |||
"write": 1 | |||
"write": 0 | |||
} | |||
], | |||
"quick_entry": 0, | |||
@@ -12,6 +12,34 @@ | |||
"editable_grid": 1, | |||
"engine": "InnoDB", | |||
"fields": [ | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"default": "", | |||
"fieldname": "client_id", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "App Client ID", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 1, | |||
"remember_last_selected_value": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
@@ -101,8 +129,7 @@ | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"default": "", | |||
"fieldname": "client_id", | |||
"fieldname": "client_secret", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
@@ -110,10 +137,11 @@ | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "App Client ID", | |||
"label": "App Client Secret", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"precision": "", | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 1, | |||
@@ -187,6 +215,7 @@ | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"default": "all openid", | |||
"description": "A list of resources which the Client App will have access to after the user allows it.<br> e.g. project", | |||
"fieldname": "scopes", | |||
"fieldtype": "Text", | |||
@@ -417,7 +446,7 @@ | |||
"issingle": 0, | |||
"istable": 0, | |||
"max_attachments": 0, | |||
"modified": "2016-11-07 05:22:49.074319", | |||
"modified": "2016-11-07 18:53:59.549740", | |||
"modified_by": "Administrator", | |||
"module": "Integration Broker", | |||
"name": "OAuth Client", | |||
@@ -9,3 +9,5 @@ from frappe.model.document import Document | |||
class OAuthClient(Document): | |||
def validate(self): | |||
self.client_id = self.name | |||
if not self.client_secret: | |||
self.client_secret = frappe.generate_hash(length=10) |
@@ -3,8 +3,10 @@ import frappe, json | |||
from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer | |||
from oauthlib.oauth2 import FatalClientError, OAuth2Error | |||
from urllib import quote, urlencode | |||
from werkzeug import url_fix | |||
from urlparse import urlparse | |||
from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings | |||
from frappe import _ | |||
#Variables required across requests | |||
oauth_validator = OAuthWebRequestValidator() | |||
@@ -23,7 +25,7 @@ def get_urlparams_from_kwargs(param_kwargs): | |||
@frappe.whitelist() | |||
def approve(*args, **kwargs): | |||
r = frappe.request | |||
uri = r.url | |||
uri = url_fix(r.url.replace("+"," ")) | |||
http_method = r.method | |||
body = r.get_data() | |||
headers = r.headers | |||
@@ -60,7 +62,7 @@ def authorize(*args, **kwargs): | |||
elif frappe.session['user']!='Guest': | |||
try: | |||
r = frappe.request | |||
uri = r.url | |||
uri = url_fix(r.url) | |||
http_method = r.method | |||
body = r.get_data() | |||
headers = r.headers | |||
@@ -94,14 +96,41 @@ def authorize(*args, **kwargs): | |||
def get_token(*args, **kwargs): | |||
r = frappe.request | |||
uri = r.url | |||
uri = url_fix(r.url) | |||
http_method = r.method | |||
body = r.form | |||
headers = r.headers | |||
#Check whether frappe server URL is set | |||
frappe_server_url = frappe.db.get_value("Social Login Keys", None, "frappe_server_url") or None | |||
if not frappe_server_url: | |||
frappe.throw(_("Define Frappe Server URL in Social Login Keys")) | |||
try: | |||
headers, body, status = oauth_server.create_token_response(uri, http_method, body, headers, credentials) | |||
frappe.local.response = frappe._dict(json.loads(body)) | |||
out = frappe._dict(json.loads(body)) | |||
if not out.error and "openid" in out.scope: | |||
token_user = frappe.db.get_value("OAuth Bearer Token", out.access_token, "user") | |||
token_client = frappe.db.get_value("OAuth Bearer Token", out.access_token, "client") | |||
client_secret = frappe.db.get_value("OAuth Client", token_client, "client_secret") | |||
if token_user in ["Guest", "Administrator"]: | |||
frappe.throw(_("Logged in as Guest or Administrator")) | |||
import hashlib | |||
id_token_header = { | |||
"typ":"jwt", | |||
"alg":"HS256" | |||
} | |||
id_token = { | |||
"aud": token_client, | |||
"exp": int((frappe.db.get_value("OAuth Bearer Token", out.access_token, "expiration_time") - frappe.utils.datetime.datetime(1970, 1, 1)).total_seconds()), | |||
"sub": frappe.db.get_value("User", token_user, "frappe_userid"), | |||
"iss": frappe_server_url, | |||
"at_hash": frappe.oauth.calculate_at_hash(out.access_token, hashlib.sha256) | |||
} | |||
import jwt | |||
id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header) | |||
out.update({"id_token":id_token_encoded}) | |||
frappe.local.response = out | |||
except FatalClientError as e: | |||
return e | |||
@@ -109,7 +138,7 @@ def get_token(*args, **kwargs): | |||
@frappe.whitelist(allow_guest=True) | |||
def revoke_token(*args, **kwargs): | |||
r = frappe.request | |||
uri = r.url | |||
uri = url_fix(r.url) | |||
http_method = r.method | |||
body = r.form | |||
headers = r.headers | |||
@@ -120,4 +149,38 @@ def revoke_token(*args, **kwargs): | |||
if status == 200: | |||
return "success" | |||
else: | |||
return "bad request" | |||
return "bad request" | |||
@frappe.whitelist() | |||
def openid_profile(*args, **kwargs): | |||
picture = None | |||
first_name, last_name, avatar, name, frappe_userid = frappe.db.get_value("User", frappe.session.user, ["first_name", "last_name", "user_image", "name", "frappe_userid"]) | |||
request_url = urlparse(frappe.request.url) | |||
if avatar: | |||
if validate_url(avatar): | |||
picture = avatar | |||
else: | |||
picture = request_url.scheme + "://" + request_url.netloc + avatar | |||
user_profile = frappe._dict({ | |||
"sub": frappe_userid, | |||
"name": " ".join(filter(None, [first_name, last_name])), | |||
"given_name": first_name, | |||
"family_name": last_name, | |||
"email": name, | |||
"picture": picture | |||
}) | |||
frappe.local.response = user_profile | |||
def validate_url(url_string): | |||
from urlparse import urlparse | |||
try: | |||
result = urlparse(url_string) | |||
if result.scheme and result.scheme in ["http", "https", "ftp", "ftps"]: | |||
return True | |||
else: | |||
return False | |||
except: | |||
return False |
@@ -2,26 +2,33 @@ | |||
"allow_copy": 0, | |||
"allow_import": 0, | |||
"allow_rename": 0, | |||
"beta": 0, | |||
"creation": "2014-03-04 08:29:52", | |||
"custom": 0, | |||
"docstatus": 0, | |||
"doctype": "DocType", | |||
"document_type": "System", | |||
"editable_grid": 0, | |||
"fields": [ | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "facebook", | |||
"fieldtype": "Section Break", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Facebook", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -33,16 +40,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "facebook_client_id", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Facebook Client ID", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -54,16 +66,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "facebook_client_secret", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Facebook Client Secret", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -75,16 +92,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "google", | |||
"fieldtype": "Section Break", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Google", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -96,16 +118,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "google_client_id", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Google Client ID", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -117,16 +144,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "google_client_secret", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Google Client Secret", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -138,16 +170,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "github", | |||
"fieldtype": "Section Break", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "GitHub", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -159,16 +196,21 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "github_client_id", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "GitHub Client ID", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -180,16 +222,129 @@ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "github_client_secret", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "GitHub Client Secret", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "frappe", | |||
"fieldtype": "Section Break", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Frappe", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"precision": "", | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "frappe_client_id", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Frappe Client ID", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"precision": "", | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "frappe_client_secret", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Frappe Client Secret", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"precision": "", | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
"search_index": 0, | |||
"set_only_once": 0, | |||
"unique": 0 | |||
}, | |||
{ | |||
"allow_on_submit": 0, | |||
"bold": 0, | |||
"collapsible": 0, | |||
"columns": 0, | |||
"fieldname": "frappe_server_url", | |||
"fieldtype": "Data", | |||
"hidden": 0, | |||
"ignore_user_permissions": 0, | |||
"ignore_xss_filter": 0, | |||
"in_filter": 0, | |||
"in_list_view": 0, | |||
"in_standard_filter": 0, | |||
"label": "Frappe Server URL", | |||
"length": 0, | |||
"no_copy": 0, | |||
"permlevel": 0, | |||
"precision": "", | |||
"print_hide": 0, | |||
"print_hide_if_no_value": 0, | |||
"read_only": 0, | |||
"report_hide": 0, | |||
"reqd": 0, | |||
@@ -202,12 +357,14 @@ | |||
"hide_toolbar": 0, | |||
"icon": "icon-signin", | |||
"idx": 1, | |||
"image_view": 0, | |||
"in_create": 0, | |||
"in_dialog": 0, | |||
"is_submittable": 0, | |||
"issingle": 1, | |||
"istable": 0, | |||
"modified": "2015-08-05 08:14:52.667728", | |||
"max_attachments": 0, | |||
"modified": "2016-10-29 13:36:35.121599", | |||
"modified_by": "Administrator", | |||
"module": "Integrations", | |||
"name": "Social Login Keys", | |||
@@ -223,6 +380,7 @@ | |||
"export": 0, | |||
"if_owner": 0, | |||
"import": 0, | |||
"is_custom": 0, | |||
"permlevel": 0, | |||
"print": 0, | |||
"read": 1, | |||
@@ -234,6 +392,9 @@ | |||
"write": 1 | |||
} | |||
], | |||
"quick_entry": 0, | |||
"read_only": 0, | |||
"read_only_onload": 0 | |||
"read_only_onload": 0, | |||
"sort_order": "ASC", | |||
"track_seen": 0 | |||
} |
@@ -7,6 +7,20 @@ from __future__ import unicode_literals | |||
import frappe | |||
from frappe.model.document import Document | |||
from frappe import _ | |||
class SocialLoginKeys(Document): | |||
pass | |||
def validate(self): | |||
self.validate_frappe_server_url() | |||
def validate_frappe_server_url(self): | |||
if self.frappe_server_url: | |||
if self.frappe_server_url.endswith('/'): | |||
self.frappe_server_url = self.frappe_server_url[:-1] | |||
import requests | |||
try: | |||
r = requests.get(self.frappe_server_url + "/api/method/frappe.handler.version", timeout=5) | |||
except: | |||
frappe.throw(_("Unable to make request to the Frappe Server URL")) | |||
if r.status_code != 200: | |||
frappe.throw(_("Check Frappe Server URL")) |
@@ -1,8 +1,9 @@ | |||
import frappe, urllib | |||
from frappe import _ | |||
from urlparse import parse_qs, urlparse | |||
from oauthlib.oauth2.rfc6749.tokens import BearerToken | |||
from oauthlib.oauth2.rfc6749.grant_types import AuthorizationCodeGrant, ImplicitGrant, ResourceOwnerPasswordCredentialsGrant, ClientCredentialsGrant, RefreshTokenGrant | |||
from oauthlib.oauth2.rfc6749.grant_types import AuthorizationCodeGrant, ImplicitGrant, ResourceOwnerPasswordCredentialsGrant, ClientCredentialsGrant, RefreshTokenGrant, OpenIDConnectAuthCode | |||
from oauthlib.oauth2 import RequestValidator | |||
from oauthlib.oauth2.rfc6749.endpoints.authorization import AuthorizationEndpoint | |||
from oauthlib.oauth2.rfc6749.endpoints.token import TokenEndpoint | |||
@@ -10,6 +11,8 @@ from oauthlib.oauth2.rfc6749.endpoints.resource import ResourceEndpoint | |||
from oauthlib.oauth2.rfc6749.endpoints.revocation import RevocationEndpoint | |||
from oauthlib.common import Request | |||
separated_by = " " | |||
class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, | |||
RevocationEndpoint): | |||
@@ -32,10 +35,19 @@ class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoin | |||
""" | |||
auth_grant = AuthorizationCodeGrant(request_validator) | |||
refresh_grant = RefreshTokenGrant(request_validator) | |||
openid_connect_auth = OpenIDConnectAuthCode(request_validator) | |||
bearer = BearerToken(request_validator, token_generator, | |||
token_expires_in, refresh_token_generator) | |||
AuthorizationEndpoint.__init__(self, default_response_type='code', | |||
response_types={'code': auth_grant}, | |||
response_types={ | |||
'code': auth_grant, | |||
'code+token': openid_connect_auth, | |||
'code+id_token': openid_connect_auth, | |||
'code+token+id_token': openid_connect_auth, | |||
'code token': openid_connect_auth, | |||
'code id_token': openid_connect_auth, | |||
'code token id_token': openid_connect_auth, | |||
}, | |||
default_token_type=bearer) | |||
TokenEndpoint.__init__(self, default_grant_type='authorization_code', | |||
grant_types={ | |||
@@ -64,7 +76,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# Is the client allowed to use the supplied redirect_uri? i.e. has | |||
# the client previously registered this EXACT redirect uri. | |||
redirect_uris = frappe.db.get_value("OAuth Client", client_id, 'redirect_uris').split(';') | |||
redirect_uris = frappe.db.get_value("OAuth Client", client_id, 'redirect_uris').split(separated_by) | |||
if redirect_uri in redirect_uris: | |||
return True | |||
@@ -80,7 +92,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): | |||
# Is the client allowed to access the requested scopes? | |||
client_scopes = frappe.db.get_value("OAuth Client", client_id, 'scopes').split(';') | |||
client_scopes = frappe.db.get_value("OAuth Client", client_id, 'scopes').split(separated_by) | |||
are_scopes_valid = True | |||
@@ -92,7 +104,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
def get_default_scopes(self, client_id, request, *args, **kwargs): | |||
# Scopes a client will authorize for if none are supplied in the | |||
# authorization request. | |||
scopes = frappe.db.get_value("OAuth Client", client_id, 'scopes').split(';') | |||
scopes = frappe.db.get_value("OAuth Client", client_id, 'scopes').split(separated_by) | |||
request.scopes = scopes #Apparently this is possible. | |||
return scopes | |||
@@ -100,8 +112,11 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# Clients should only be allowed to use one type of response type, the | |||
# one associated with their one allowed grant type. | |||
# In this case it must be "code". | |||
allowed_response_types = [client.response_type.lower(), | |||
"code token", "code id_token", "code token id_token", | |||
"code+token", "code+id_token", "code+token id_token"] | |||
return (client.response_type.lower() == response_type) | |||
return (response_type in allowed_response_types) | |||
# Post-authorization | |||
@@ -111,7 +126,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
cookie_dict = get_cookie_dict_from_headers(request) | |||
oac = frappe.new_doc('OAuth Authorization Code') | |||
oac.scopes = ';'.join(request.scopes) | |||
oac.scopes = separated_by.join(request.scopes) | |||
oac.redirect_uri_bound_to_authorization_code = request.redirect_uri | |||
oac.client = client_id | |||
oac.user = urllib.unquote(cookie_dict['user_id']) | |||
@@ -130,8 +145,10 @@ class OAuthWebRequestValidator(RequestValidator): | |||
#Extract token, instantiate OAuth Bearer Token and use clientid from there. | |||
if frappe.form_dict.has_key("refresh_token"): | |||
oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", {"refresh_token": frappe.form_dict["refresh_token"]}, 'client')) | |||
else: | |||
elif frappe.form_dict.has_key("token"): | |||
oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", frappe.form_dict["token"], 'client')) | |||
else: | |||
oc = frappe.get_doc("OAuth Client", frappe.db.get_value("OAuth Bearer Token", frappe.get_request_header("Authorization").split(" ")[1], 'client')) | |||
try: | |||
request.client = request.client or oc.as_dict() | |||
except Exception, e: | |||
@@ -159,7 +176,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
checkcodes.append(vcode["name"]) | |||
if code in checkcodes: | |||
request.scopes = frappe.db.get_value("OAuth Authorization Code", code, 'scopes').split(';') | |||
request.scopes = frappe.db.get_value("OAuth Authorization Code", code, 'scopes').split(separated_by) | |||
request.user = frappe.db.get_value("OAuth Authorization Code", code, 'user') | |||
return True | |||
else: | |||
@@ -185,7 +202,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
otoken = frappe.new_doc("OAuth Bearer Token") | |||
otoken.client = request.client['name'] | |||
otoken.user = request.user | |||
otoken.scopes = ";".join(request.scopes) | |||
otoken.scopes = separated_by.join(request.scopes) | |||
otoken.access_token = token['access_token'] | |||
otoken.refresh_token = token['refresh_token'] | |||
otoken.expires_in = token['expires_in'] | |||
@@ -209,7 +226,7 @@ class OAuthWebRequestValidator(RequestValidator): | |||
otoken = frappe.get_doc("OAuth Bearer Token", token) #{"access_token": str(token)}) | |||
is_token_valid = (frappe.utils.datetime.datetime.now() < otoken.expiration_time) \ | |||
and otoken.status != "Revoked" | |||
client_scopes = frappe.db.get_value("OAuth Client", otoken.client, 'scopes').split(';') | |||
client_scopes = frappe.db.get_value("OAuth Client", otoken.client, 'scopes').split(separated_by) | |||
are_scopes_valid = True | |||
for scp in scopes: | |||
are_scopes_valid = are_scopes_valid and True if scp in client_scopes else False | |||
@@ -261,7 +278,6 @@ class OAuthWebRequestValidator(RequestValidator): | |||
# - Resource Owner Password Credentials Grant (also indirectly) | |||
# - Refresh Token Grant | |||
# """ | |||
# raise NotImplementedError('Subclasses must implement this method.') | |||
otoken = frappe.get_doc("OAuth Bearer Token", {"refresh_token": refresh_token, "status": "Active"}) | |||
@@ -270,7 +286,101 @@ class OAuthWebRequestValidator(RequestValidator): | |||
else: | |||
return True | |||
#TODO: Validate scopes. | |||
# OpenID Connect | |||
def get_id_token(self, token, token_handler, request): | |||
""" | |||
In the OpenID Connect workflows when an ID Token is requested this method is called. | |||
Subclasses should implement the construction, signing and optional encryption of the | |||
ID Token as described in the OpenID Connect spec. | |||
In addition to the standard OAuth2 request properties, the request may also contain | |||
these OIDC specific properties which are useful to this method: | |||
- nonce, if workflow is implicit or hybrid and it was provided | |||
- claims, if provided to the original Authorization Code request | |||
The token parameter is a dict which may contain an ``access_token`` entry, in which | |||
case the resulting ID Token *should* include a calculated ``at_hash`` claim. | |||
Similarly, when the request parameter has a ``code`` property defined, the ID Token | |||
*should* include a calculated ``c_hash`` claim. | |||
http://openid.net/specs/openid-connect-core-1_0.html (sections `3.1.3.6`_, `3.2.2.10`_, `3.3.2.11`_) | |||
.. _`3.1.3.6`: http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken | |||
.. _`3.2.2.10`: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken | |||
.. _`3.3.2.11`: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken | |||
:param token: A Bearer token dict | |||
:param token_handler: the token handler (BearerToken class) | |||
:param request: the HTTP Request (oauthlib.common.Request) | |||
:return: The ID Token (a JWS signed JWT) | |||
""" | |||
# the request.scope should be used by the get_id_token() method to determine which claims to include in the resulting id_token | |||
def validate_silent_authorization(self, request): | |||
"""Ensure the logged in user has authorized silent OpenID authorization. | |||
Silent OpenID authorization allows access tokens and id tokens to be | |||
granted to clients without any user prompt or interaction. | |||
:param request: The HTTP Request (oauthlib.common.Request) | |||
:rtype: True or False | |||
Method is used by: | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
""" | |||
if request.prompt == "login": | |||
False | |||
else: | |||
True | |||
def validate_silent_login(self, request): | |||
"""Ensure session user has authorized silent OpenID login. | |||
If no user is logged in or has not authorized silent login, this | |||
method should return False. | |||
If the user is logged in but associated with multiple accounts and | |||
not selected which one to link to the token then this method should | |||
raise an oauthlib.oauth2.AccountSelectionRequired error. | |||
:param request: The HTTP Request (oauthlib.common.Request) | |||
:rtype: True or False | |||
Method is used by: | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
""" | |||
if frappe.session.user == "Guest" or request.prompt.lower() == "login": | |||
return False | |||
else: | |||
return True | |||
def validate_user_match(self, id_token_hint, scopes, claims, request): | |||
"""Ensure client supplied user id hint matches session user. | |||
If the sub claim or id_token_hint is supplied then the session | |||
user must match the given ID. | |||
:param id_token_hint: User identifier string. | |||
:param scopes: List of OAuth 2 scopes and OpenID claims (strings). | |||
:param claims: OpenID Connect claims dict. | |||
:param request: The HTTP Request (oauthlib.common.Request) | |||
:rtype: True or False | |||
Method is used by: | |||
- OpenIDConnectAuthCode | |||
- OpenIDConnectImplicit | |||
- OpenIDConnectHybrid | |||
""" | |||
if id_token_hint and id_token_hint == frappe.get_value("User", frappe.session.user, "frappe_userid"): | |||
return True | |||
else: | |||
return False | |||
def get_cookie_dict_from_headers(r): | |||
if r.headers.get('Cookie'): | |||
@@ -280,3 +390,39 @@ def get_cookie_dict_from_headers(r): | |||
return cookie_dict | |||
else: | |||
return {} | |||
def calculate_at_hash(access_token, hash_alg): | |||
"""Helper method for calculating an access token | |||
hash, as described in http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken | |||
Its value is the base64url encoding of the left-most half of the hash of the octets | |||
of the ASCII representation of the access_token value, where the hash algorithm | |||
used is the hash algorithm used in the alg Header Parameter of the ID Token's JOSE | |||
Header. For instance, if the alg is RS256, hash the access_token value with SHA-256, | |||
then take the left-most 128 bits and base64url encode them. The at_hash value is a | |||
case sensitive string. | |||
Args: | |||
access_token (str): An access token string. | |||
hash_alg (callable): A callable returning a hash object, e.g. hashlib.sha256 | |||
""" | |||
hash_digest = hash_alg(access_token.encode('utf-8')).digest() | |||
cut_at = int(len(hash_digest) / 2) | |||
truncated = hash_digest[:cut_at] | |||
from jwt.utils import base64url_encode | |||
at_hash = base64url_encode(truncated) | |||
return at_hash.decode('utf-8') | |||
def delete_oauth2_data(): | |||
# Delete Invalid Authorization Code and Revoked Token | |||
commit_code, commit_token = False, False | |||
code_list = frappe.get_all("OAuth Authorization Code", filters={"validity":"Invalid"}) | |||
token_list = frappe.get_all("OAuth Bearer Token", filters={"status":"Revoked"}) | |||
if len(code_list) > 0: | |||
commit_code = True | |||
if len(token_list) > 0: | |||
commit_token = True | |||
for code in code_list: | |||
frappe.delete_doc("OAuth Authorization Code", code["name"]) | |||
for token in token_list: | |||
frappe.delete_doc("OAuth Bearer Token", token["name"]) | |||
if commit_code or commit_token: | |||
frappe.db.commit() |
@@ -10,6 +10,9 @@ from frappe import _ | |||
class SignupDisabledError(frappe.PermissionError): pass | |||
def get_oauth2_providers(): | |||
frappe_server_url = frappe.db.get_value("Social Login Keys", None, "frappe_server_url") or None | |||
if not frappe_server_url: | |||
frappe.throw(_("Define Frappe Server URL in Social Login Keys")) | |||
return { | |||
"google": { | |||
"flow_params": { | |||
@@ -64,7 +67,26 @@ def get_oauth2_providers(): | |||
"api_endpoint": "/v2.5/me", | |||
"api_endpoint_args": { | |||
"fields": "first_name,last_name,email,gender,location,verified,picture" | |||
} | |||
}, | |||
}, | |||
"frappe": { | |||
"flow_params": { | |||
"name": "frappe", | |||
"authorize_url": frappe_server_url + "/api/method/frappe.integration_broker.oauth2.authorize", | |||
"access_token_url": frappe_server_url + "/api/method/frappe.integration_broker.oauth2.get_token", | |||
"base_url": frappe_server_url | |||
}, | |||
"redirect_uri": "/api/method/frappe.www.login.login_via_frappe", | |||
"auth_url_data": { | |||
"response_type": "code", | |||
"scope": "openid" | |||
}, | |||
# relative to base_url | |||
"api_endpoint": "/api/method/frappe.integration_broker.oauth2.openid_profile" | |||
} | |||
} | |||
@@ -282,6 +304,10 @@ def update_oauth_user(user, data, provider): | |||
user.github_userid = data["id"] | |||
user.github_username = data["login"] | |||
elif provider=="frappe" and not user.get("frappe_userid"): | |||
save = True | |||
user.frappe_userid = data["sub"] | |||
if save: | |||
user.flags.ignore_permissions = True | |||
user.flags.no_welcome_mail = True | |||
@@ -47,6 +47,11 @@ | |||
<a href="{{ github_login }}" class="no-decoration btn-social btn-github"> | |||
<i class="icon-github-sign icon-2x"></i></a> | |||
{%- endif -%} | |||
{%- if frappe_login is defined %} | |||
<a href="{{ frappe_login }}" class="no-decoration"> | |||
<img src="/assets/frappe/images/favicon.png"></a> | |||
{%- endif -%} | |||
</p> | |||
{%- endif -%} | |||
{% if ldap_settings.enabled %} | |||
@@ -23,7 +23,7 @@ def get_context(context): | |||
context["title"] = "Login" | |||
context["disable_signup"] = frappe.utils.cint(frappe.db.get_value("Website Settings", "Website Settings", "disable_signup")) | |||
for provider in ("google", "github", "facebook"): | |||
for provider in ("google", "github", "facebook", "frappe"): | |||
if get_oauth_keys(provider): | |||
context["{provider}_login".format(provider=provider)] = get_oauth2_authorize_url(provider) | |||
context["social_login"] = True | |||
@@ -45,6 +45,10 @@ def login_via_github(code, state): | |||
def login_via_facebook(code, state): | |||
login_via_oauth2("facebook", code, state) | |||
@frappe.whitelist(allow_guest=True) | |||
def login_via_frappe(code, state): | |||
login_via_oauth2("frappe", code, state, decoder=json.loads) | |||
@frappe.whitelist(allow_guest=True) | |||
def login_oauth_user(data=None, provider=None, state=None, email_id=None, key=None, generate_login_token=False): | |||
if not ((data and provider and state) or (email_id and key)): | |||
@@ -39,3 +39,4 @@ psutil | |||
unittest-xml-reporting | |||
xlwt | |||
oauthlib | |||
PyJWT |