Преглед изворни кода

OpenID Connect for Frappe (#2227)

* 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 scope
version-14
Revant Nandgaonkar пре 8 година
committed by Rushabh Mehta
родитељ
комит
a16e6a143f
25 измењених фајлова са 591 додато и 35 уклоњено
  1. +29
    -1
      frappe/core/doctype/user/user.json
  2. +3
    -0
      frappe/core/doctype/user/user.py
  3. BIN
      frappe/docs/assets/img/00-login-to-idp.png
  4. BIN
      frappe/docs/assets/img/01-add-oauth-client-on-idp.png
  5. BIN
      frappe/docs/assets/img/02-set-server-url-on-idp.png
  6. BIN
      frappe/docs/assets/img/03-set-clientid-client-secret-server-on-app-server.png
  7. BIN
      frappe/docs/assets/img/04-login-screen-on-app-server.png
  8. BIN
      frappe/docs/assets/img/05-login-with-user-on-idp.png
  9. BIN
      frappe/docs/assets/img/06-confirm-grant-access-on-idp.png
  10. BIN
      frappe/docs/assets/img/07-logged-in-as-website-user-with-id-from-idp.png
  11. +1
    -0
      frappe/docs/user/en/guides/integration/index.txt
  12. +72
    -0
      frappe/docs/user/en/guides/integration/openid_connect_and_frappe_social_login.md
  13. +1
    -0
      frappe/hooks.py
  14. +3
    -3
      frappe/integration_broker/doctype/oauth_authorization_code/oauth_authorization_code.json
  15. +3
    -3
      frappe/integration_broker/doctype/oauth_bearer_token/oauth_bearer_token.json
  16. +33
    -4
      frappe/integration_broker/doctype/oauth_client/oauth_client.json
  17. +2
    -0
      frappe/integration_broker/doctype/oauth_client/oauth_client.py
  18. +69
    -6
      frappe/integration_broker/oauth2.py
  19. +163
    -2
      frappe/integrations/doctype/social_login_keys/social_login_keys.json
  20. +15
    -1
      frappe/integrations/doctype/social_login_keys/social_login_keys.py
  21. +159
    -13
      frappe/oauth.py
  22. +27
    -1
      frappe/utils/oauth.py
  23. +5
    -0
      frappe/www/login.html
  24. +5
    -1
      frappe/www/login.py
  25. +1
    -0
      requirements.txt

+ 29
- 1
frappe/core/doctype/user/user.json Прегледај датотеку

@@ -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",


+ 3
- 0
frappe/core/doctype/user/user.py Прегледај датотеку

@@ -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()


BIN
frappe/docs/assets/img/00-login-to-idp.png Прегледај датотеку

Before After
Width: 1304  |  Height: 682  |  Size: 20 KiB

BIN
frappe/docs/assets/img/01-add-oauth-client-on-idp.png Прегледај датотеку

Before After
Width: 1291  |  Height: 571  |  Size: 74 KiB

BIN
frappe/docs/assets/img/02-set-server-url-on-idp.png Прегледај датотеку

Before After
Width: 1303  |  Height: 432  |  Size: 24 KiB

BIN
frappe/docs/assets/img/03-set-clientid-client-secret-server-on-app-server.png Прегледај датотеку

Before After
Width: 1294  |  Height: 510  |  Size: 22 KiB

BIN
frappe/docs/assets/img/04-login-screen-on-app-server.png Прегледај датотеку

Before After
Width: 1298  |  Height: 667  |  Size: 19 KiB

BIN
frappe/docs/assets/img/05-login-with-user-on-idp.png Прегледај датотеку

Before After
Width: 1300  |  Height: 681  |  Size: 18 KiB

BIN
frappe/docs/assets/img/06-confirm-grant-access-on-idp.png Прегледај датотеку

Before After
Width: 1317  |  Height: 681  |  Size: 22 KiB

BIN
frappe/docs/assets/img/07-logged-in-as-website-user-with-id-from-idp.png Прегледај датотеку

Before After
Width: 1300  |  Height: 679  |  Size: 24 KiB

+ 1
- 0
frappe/docs/user/en/guides/integration/index.txt Прегледај датотеку

@@ -1,3 +1,4 @@
rest_api
how_to_setup_oauth
using_oauth
openid_connect_and_frappe_social_login

+ 72
- 0
frappe/docs/user/en/guides/integration/openid_connect_and_frappe_social_login.md Прегледај датотеку

@@ -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">

+ 1
- 0
frappe/hooks.py Прегледај датотеку

@@ -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",


+ 3
- 3
frappe/integration_broker/doctype/oauth_authorization_code/oauth_authorization_code.json Прегледај датотеку

@@ -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,


+ 3
- 3
frappe/integration_broker/doctype/oauth_bearer_token/oauth_bearer_token.json Прегледај датотеку

@@ -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,


+ 33
- 4
frappe/integration_broker/doctype/oauth_client/oauth_client.json Прегледај датотеку

@@ -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",


+ 2
- 0
frappe/integration_broker/doctype/oauth_client/oauth_client.py Прегледај датотеку

@@ -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)

+ 69
- 6
frappe/integration_broker/oauth2.py Прегледај датотеку

@@ -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

+ 163
- 2
frappe/integrations/doctype/social_login_keys/social_login_keys.json Прегледај датотеку

@@ -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
}

+ 15
- 1
frappe/integrations/doctype/social_login_keys/social_login_keys.py Прегледај датотеку

@@ -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"))

+ 159
- 13
frappe/oauth.py Прегледај датотеку

@@ -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()

+ 27
- 1
frappe/utils/oauth.py Прегледај датотеку

@@ -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


+ 5
- 0
frappe/www/login.html Прегледај датотеку

@@ -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 %}


+ 5
- 1
frappe/www/login.py Прегледај датотеку

@@ -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)):


+ 1
- 0
requirements.txt Прегледај датотеку

@@ -39,3 +39,4 @@ psutil
unittest-xml-reporting
xlwt
oauthlib
PyJWT

Loading…
Откажи
Сачувај