From 63b95e71a705c0390d085ac5f90d4c4057c428bf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 13 Apr 2021 08:57:08 +0200 Subject: [PATCH] :tada: Add generic oauth2/openid-connect authentication subsystem. --- CHANGES.md | 3 +- backend/src/app/config.clj | 12 + backend/src/app/http.clj | 11 +- backend/src/app/http/oauth.clj | 278 ++++++++++++++++++ backend/src/app/http/oauth/github.clj | 157 ---------- backend/src/app/http/oauth/gitlab.clj | 166 ----------- backend/src/app/http/oauth/google.clj | 181 ------------ backend/src/app/main.clj | 33 +-- docker/images/files/config.js | 1 + docker/images/files/nginx-entrypoint.sh | 9 + .../resources/styles/main/layouts/login.scss | 2 +- frontend/src/app/config.cljs | 1 + frontend/src/app/main/repo.cljs | 22 +- frontend/src/app/main/ui/auth/login.cljs | 68 ++--- frontend/src/app/main/ui/auth/register.cljs | 20 +- frontend/translations/en.po | 12 +- frontend/translations/es.po | 12 +- 17 files changed, 368 insertions(+), 620 deletions(-) create mode 100644 backend/src/app/http/oauth.clj delete mode 100644 backend/src/app/http/oauth/github.clj delete mode 100644 backend/src/app/http/oauth/gitlab.clj delete mode 100644 backend/src/app/http/oauth/google.clj diff --git a/CHANGES.md b/CHANGES.md index 3eb09081c..d979bb597 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,7 +8,8 @@ - Allow to group assets (components and graphics) [Taiga #1289](https://tree.taiga.io/project/penpot/us/1289) - Internal: refactor of http client, replace internal xhr usage with more modern Fetch API. - New features for paths: snap points on edition, add/remove nodes, merge/join/split nodes. [Taiga #907](https://tree.taiga.io/project/penpot/us/907) - +- Add OpenID-Connect support. +- Reimplement social auth providers on top of the generic openid impl. ### :bug: Bugs fixed diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 3d73599f0..3518d601e 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -105,6 +105,12 @@ (s/def ::gitlab-client-secret ::us/string) (s/def ::google-client-id ::us/string) (s/def ::google-client-secret ::us/string) +(s/def ::oidc-client-id ::us/string) +(s/def ::oidc-client-secret ::us/string) +(s/def ::oidc-base-uri ::us/string) +(s/def ::oidc-token-uri ::us/string) +(s/def ::oidc-auth-uri ::us/string) +(s/def ::oidc-user-uri ::us/string) (s/def ::host ::us/string) (s/def ::http-server-port ::us/integer) (s/def ::http-session-cookie-name ::us/string) @@ -178,6 +184,12 @@ ::gitlab-client-secret ::google-client-id ::google-client-secret + ::oidc-client-id + ::oidc-client-secret + ::oidc-base-uri + ::oidc-token-uri + ::oidc-auth-uri + ::oidc-user-uri ::host ::http-server-port ::http-session-idle-max-age diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 3e3504cda..4ad574102 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -149,15 +149,8 @@ ["/feedback" {:middleware [(:middleware session)] :post feedback}] - ["/oauth" - ["/google" {:post (get-in oauth [:google :handler])}] - ["/google/callback" {:get (get-in oauth [:google :callback-handler])}] - - ["/gitlab" {:post (get-in oauth [:gitlab :handler])}] - ["/gitlab/callback" {:get (get-in oauth [:gitlab :callback-handler])}] - - ["/github" {:post (get-in oauth [:github :handler])}] - ["/github/callback" {:get (get-in oauth [:github :callback-handler])}]] + ["/auth/oauth/:provider" {:post (:handler oauth)}] + ["/auth/oauth/:provider/callback" {:get (:callback-handler oauth)}] ["/rpc" {:middleware [(:middleware session) middleware/activity-logger]} diff --git a/backend/src/app/http/oauth.clj b/backend/src/app/http/oauth.clj new file mode 100644 index 000000000..20866091b --- /dev/null +++ b/backend/src/app/http/oauth.clj @@ -0,0 +1,278 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.http.oauth + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.config :as cf] + [app.util.http :as http] + [app.util.logging :as l] + [app.util.time :as dt] + [clojure.data.json :as json] + [clojure.spec.alpha :as s] + [integrant.core :as ig] + [lambdaisland.uri :as u])) + +(defn redirect-response + [uri] + {:status 302 + :headers {"location" (str uri)} + :body ""}) + +(defn generate-error-redirect-uri + [cfg] + (-> (u/uri (:public-uri cfg)) + (assoc :path "/#/auth/login") + (assoc :query (u/map->query-string {:error "unable-to-auth"})))) + +(defn register-profile + [{:keys [rpc] :as cfg} info] + (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) + profile (method-fn {:email (:email info) + :backend (:backend info) + :fullname (:fullname info)})] + (cond-> profile + (some? (:invitation-token info)) + (assoc :invitation-token (:invitation-token info))))) + +(defn generate-redirect-uri + [{:keys [tokens] :as cfg} profile] + (let [token (or (:invitation-token profile) + (tokens :generate {:iss :auth + :exp (dt/in-future "15m") + :profile-id (:id profile)}))] + (-> (u/uri (:public-uri cfg)) + (assoc :path "/#/auth/verify-token") + (assoc :query (u/map->query-string {:token token}))))) + +(defn- build-redirect-uri + [{:keys [provider] :as cfg}] + (let [public (u/uri (:public-uri cfg))] + (str (assoc public :path (str "/api/auth/oauth/" (:name provider) "/callback"))))) + +(defn- build-auth-uri + [{:keys [provider] :as cfg} state] + (let [params {:client_id (:client-id provider) + :redirect_uri (build-redirect-uri cfg) + :response_type "code" + :state state + :scope (:scope provider)} + query (u/map->query-string params)] + (-> (u/uri (:auth-uri provider)) + (assoc :query query) + (str)))) + +(defn retrieve-access-token + [{:keys [provider] :as cfg} code] + (try + (let [params {:client_id (:client-id provider) + :client_secret (:client-secret provider) + :code code + :grant_type "authorization_code" + :redirect_uri (build-redirect-uri cfg)} + req {:method :post + :headers {"content-type" "application/x-www-form-urlencoded"} + :uri (:token-uri provider) + :body (u/map->query-string params)} + res (http/send! req)] + (when (= 200 (:status res)) + (let [data (json/read-str (:body res))] + {:token (get data "access_token") + :type (get data "token_type")}))) + (catch Exception e + (l/error :hint "unexpected error on retrieve-access-token" + :cause e) + nil))) + +(defn- retrieve-user-info + [{:keys [provider] :as cfg} tdata] + (try + (let [req {:uri (:user-uri provider) + :headers {"Authorization" (str (:type tdata) " " (:token tdata))} + :timeout 6000 + :method :get} + res (http/send! req)] + + (when (= 200 (:status res)) + (let [data (json/read-str (:body res))] + {:email (get data "email") + :backend (:name provider) + :fullname (get data "name")}))) + + (catch Exception e + (l/error :hint "unexpected exception on retrieve-user-info" + :cause e) + nil))) + +(defn retrieve-info + [{:keys [tokens] :as cfg} request] + (let [state (get-in request [:params :state]) + state (tokens :verify {:token state :iss :oauth}) + info (some->> (get-in request [:params :code]) + (retrieve-access-token cfg) + (retrieve-user-info cfg))] + (when-not info + (ex/raise :type :internal + :code :unable-to-auth)) + + (cond-> info + (some? (:invitation-token state)) + (assoc :invitation-token (:invitation-token state))))) + +;; --- HTTP HANDLERS + +(defn- auth-handler + [{:keys [tokens] :as cfg} request] + (let [invitation (get-in request [:params :invitation-token]) + state (tokens :generate + {:iss :oauth + :invitation-token invitation + :exp (dt/in-future "15m")}) + uri (build-auth-uri cfg state)] + {:status 200 + :body {:redirect-uri uri}})) + +(defn- callback-handler + [{:keys [session] :as cfg} request] + (try + (let [info (retrieve-info cfg request) + profile (register-profile cfg info) + uri (generate-redirect-uri cfg profile) + sxf ((:create session) (:id profile))] + (->> (redirect-response uri) + (sxf request))) + (catch Exception _e + (-> (generate-error-redirect-uri cfg) + (redirect-response))))) + +;; --- INIT + +(declare initialize) + +(s/def ::public-uri ::us/not-empty-string) +(s/def ::session map?) +(s/def ::tokens fn?) +(s/def ::rpc map?) + +(defmethod ig/pre-init-spec :app.http.oauth/handlers [_] + (s/keys :req-un [::public-uri ::session ::tokens ::rpc])) + +(defn wrap-handler + [cfg handler] + (fn [request] + (let [provider (get-in request [:path-params :provider]) + provider (get-in @cfg [:providers provider])] + (when-not provider + (ex/raise :type :not-found + :context {:provider provider} + :hint "provider not configured")) + (-> (assoc @cfg :provider provider) + (handler request))))) + +(defmethod ig/init-key :app.http.oauth/handlers + [_ cfg] + (let [cfg (initialize cfg)] + {:handler (wrap-handler cfg auth-handler) + :callback-handler (wrap-handler cfg callback-handler)})) + +(defn- discover-oidc-config + [{:keys [base-uri] :as opts}] + (let [discovery-uri (u/join base-uri ".well-known/openid-configuration") + response (http/send! {:method :get :uri (str discovery-uri)})] + (when (= 200 (:status response)) + (let [data (json/read-str (:body response))] + (assoc opts + :token-uri (get data "token_endpoint") + :auth-uri (get data "authorization_endpoint") + :user-uri (get data "userinfo_endpoint")))))) + +(defn- initialize-oidc-provider + [cfg] + (let [opts {:base-uri (cf/get :oidc-base-uri) + :client-id (cf/get :oidc-client-id) + :client-secret (cf/get :oidc-client-secret) + :token-uri (cf/get :oidc-token-uri) + :auth-uri (cf/get :oidc-auth-uri) + :user-uri (cf/get :oidc-user-uri) + :scope "openid profile email name" + :name "oidc"}] + (if (and (string? (:base-uri opts)) + (string? (:client-id opts)) + (string? (:client-secret opts))) + (if (and (string? (:token-uri opts)) + (string? (:user-uri opts)) + (string? (:auth-uri opts))) + (do + (l/info :action "initialize" :provider "oid" :method "static") + (assoc-in cfg [:providers "oidc"] opts)) + (let [opts (discover-oidc-config opts)] + (l/info :action "initialize" :provider "oid" :method "discover") + (assoc-in cfg [:providers "oidc"] opts))) + cfg))) + +(defn- initialize-google-provider + [cfg] + (let [opts {:client-id (cf/get :google-client-id) + :client-secret (cf/get :google-client-secret) + :scope (str "email profile " + "https://www.googleapis.com/auth/userinfo.email " + "https://www.googleapis.com/auth/userinfo.profile " + "openid") + :auth-uri "https://accounts.google.com/o/oauth2/v2/auth" + :token-uri "https://oauth2.googleapis.com/token" + :user-uri "https://openidconnect.googleapis.com/v1/userinfo" + :name "google"}] + (if (and (string? (:client-id opts)) + (string? (:client-secret opts))) + (do + (l/info :action "initialize" :provider "google") + (assoc-in cfg [:providers "google"] opts)) + cfg))) + +(defn- initialize-github-provider + [cfg] + (let [opts {:client-id (cf/get :github-client-id) + :client-secret (cf/get :github-client-secret) + :scope "user:email" + :auth-uri "https://github.com/login/oauth/authorize" + :token-uri "https://github.com/login/oauth/access_token" + :user-uri "https://api.github.com/user" + :name "github"}] + (if (and (string? (:client-id opts)) + (string? (:client-secret opts))) + (do + (l/info :action "initialize" :provider "github") + (assoc-in cfg [:providers "github"] opts)) + cfg))) + + +(defn- initialize-gitlab-provider + [cfg] + (let [base (cf/get :gitlab-base-uri "https://gitlab.com") + opts {:base-uri base + :client-id (cf/get :gitlab-client-id) + :client-secret (cf/get :gitlab-client-secret) + :scope "read_user" + :auth-uri (str base "/oauth/authorize") + :token-uri (str base "/oauth/token") + :user-uri (str base "/api/v4/user") + :name "gitlab"}] + (if (and (string? (:client-id opts)) + (string? (:client-secret opts))) + (do + (l/info :action "initialize" :provider "gitlab") + (assoc-in cfg [:providers "gitlab"] opts)) + cfg))) + +(defn- initialize + [cfg] + (let [cfg (agent cfg :error-mode :continue)] + (send-off cfg initialize-google-provider) + (send-off cfg initialize-gitlab-provider) + (send-off cfg initialize-github-provider) + (send-off cfg initialize-oidc-provider) + cfg)) diff --git a/backend/src/app/http/oauth/github.clj b/backend/src/app/http/oauth/github.clj deleted file mode 100644 index c48d9b401..000000000 --- a/backend/src/app/http/oauth/github.clj +++ /dev/null @@ -1,157 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) UXBOX Labs SL - -(ns app.http.oauth.github - (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.http.oauth.google :as gg] - [app.util.http :as http] - [app.util.logging :as l] - [app.util.time :as dt] - [clojure.data.json :as json] - [clojure.spec.alpha :as s] - [integrant.core :as ig] - [lambdaisland.uri :as u])) - -(def base-github-uri - (u/uri "https://github.com")) - -(def base-api-github-uri - (u/uri "https://api.github.com")) - -(def authorize-uri - (assoc base-github-uri :path "/login/oauth/authorize")) - -(def token-url - (assoc base-github-uri :path "/login/oauth/access_token")) - -(def user-info-url - (assoc base-api-github-uri :path "/user")) - -(def scope "user:email") - -(defn- build-redirect-url - [cfg] - (let [public (u/uri (:public-uri cfg))] - (str (assoc public :path "/api/oauth/github/callback")))) - -(defn- get-access-token - [cfg state code] - (try - (let [params {:client_id (:client-id cfg) - :client_secret (:client-secret cfg) - :code code - :state state - :redirect_uri (build-redirect-url cfg)} - req {:method :post - :headers {"content-type" "application/x-www-form-urlencoded" - "accept" "application/json"} - :uri (str token-url) - :timeout 6000 - :body (u/map->query-string params)} - res (http/send! req)] - - (when (= 200 (:status res)) - (-> (json/read-str (:body res)) - (get "access_token")))) - - (catch Exception e - (l/error :hint "unexpected error on get-access-token" - :cause e) - nil))) - -(defn- get-user-info - [_ token] - (try - (let [req {:uri (str user-info-url) - :headers {"authorization" (str "token " token)} - :timeout 6000 - :method :get} - res (http/send! req)] - (when (= 200 (:status res)) - (let [data (json/read-str (:body res))] - {:email (get data "email") - :backend "github" - :fullname (get data "name")}))) - (catch Exception e - (l/error :hint "unexpected exception on get-user-info" - :cause e) - nil))) - -(defn- retrieve-info - [{:keys [tokens] :as cfg} request] - (let [token (get-in request [:params :state]) - state (tokens :verify {:token token :iss :github-oauth}) - info (some->> (get-in request [:params :code]) - (get-access-token cfg state) - (get-user-info cfg))] - (when-not info - (ex/raise :type :internal - :code :unable-to-auth)) - - (cond-> info - (some? (:invitation-token state)) - (assoc :invitation-token (:invitation-token state))))) - -(defn auth-handler - [{:keys [tokens] :as cfg} request] - (let [invitation (get-in request [:params :invitation-token]) - state (tokens :generate {:iss :github-oauth - :invitation-token invitation - :exp (dt/in-future "15m")}) - params {:client_id (:client-id cfg) - :redirect_uri (build-redirect-url cfg) - :state state - :scope scope} - query (u/map->query-string params) - uri (-> authorize-uri - (assoc :query query))] - {:status 200 - :body {:redirect-uri (str uri)}})) - -(defn- callback-handler - [{:keys [session] :as cfg} request] - (try - (let [info (retrieve-info cfg request) - profile (gg/register-profile cfg info) - uri (gg/generate-redirect-uri cfg profile) - sxf ((:create session) (:id profile))] - (->> (gg/redirect-response uri) - (sxf request))) - (catch Exception _e - (-> (gg/generate-error-redirect-uri cfg) - (gg/redirect-response))))) - - -;; --- ENTRY POINT - -(s/def ::client-id ::us/not-empty-string) -(s/def ::client-secret ::us/not-empty-string) -(s/def ::public-uri ::us/not-empty-string) -(s/def ::session map?) -(s/def ::tokens fn?) - -(defmethod ig/pre-init-spec :app.http.oauth/github [_] - (s/keys :req-un [::public-uri - ::session - ::tokens] - :opt-un [::client-id - ::client-secret])) - -(defn- default-handler - [_] - (ex/raise :type :not-found)) - -(defmethod ig/init-key :app.http.oauth/github - [_ cfg] - (if (and (:client-id cfg) - (:client-secret cfg)) - {:handler #(auth-handler cfg %) - :callback-handler #(callback-handler cfg %)} - {:handler default-handler - :callback-handler default-handler})) - diff --git a/backend/src/app/http/oauth/gitlab.clj b/backend/src/app/http/oauth/gitlab.clj deleted file mode 100644 index 58c81396f..000000000 --- a/backend/src/app/http/oauth/gitlab.clj +++ /dev/null @@ -1,166 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) UXBOX Labs SL - -(ns app.http.oauth.gitlab - (:require - [app.common.data :as d] - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.http.oauth.google :as gg] - [app.util.http :as http] - [app.util.logging :as l] - [app.util.time :as dt] - [clojure.data.json :as json] - [clojure.spec.alpha :as s] - [integrant.core :as ig] - [lambdaisland.uri :as u])) - -(def scope "read_user") - -(defn- build-redirect-url - [cfg] - (let [public (u/uri (:public-uri cfg))] - (str (assoc public :path "/api/oauth/gitlab/callback")))) - -(defn- build-oauth-uri - [cfg] - (let [base-uri (u/uri (:base-uri cfg))] - (assoc base-uri :path "/oauth/authorize"))) - -(defn- build-token-url - [cfg] - (let [base-uri (u/uri (:base-uri cfg))] - (str (assoc base-uri :path "/oauth/token")))) - -(defn- build-user-info-url - [cfg] - (let [base-uri (u/uri (:base-uri cfg))] - (str (assoc base-uri :path "/api/v4/user")))) - -(defn- get-access-token - [cfg code] - (try - (let [params {:client_id (:client-id cfg) - :client_secret (:client-secret cfg) - :code code - :grant_type "authorization_code" - :redirect_uri (build-redirect-url cfg)} - req {:method :post - :headers {"content-type" "application/x-www-form-urlencoded"} - :uri (build-token-url cfg) - :body (u/map->query-string params)} - res (http/send! req)] - - (when (= 200 (:status res)) - (-> (json/read-str (:body res)) - (get "access_token")))) - - (catch Exception e - (l/error :hint "unexpected error on get-access-token" - :cause e) - nil))) - -(defn- get-user-info - [cfg token] - (try - (let [req {:uri (build-user-info-url cfg) - :headers {"Authorization" (str "Bearer " token)} - :timeout 6000 - :method :get} - res (http/send! req)] - - (when (= 200 (:status res)) - (let [data (json/read-str (:body res))] - {:email (get data "email") - :backend "gitlab" - :fullname (get data "name")}))) - - (catch Exception e - (l/error :hint "unexpected exception on get-user-info" - :cause e) - nil))) - - -(defn- retrieve-info - [{:keys [tokens] :as cfg} request] - (let [token (get-in request [:params :state]) - state (tokens :verify {:token token :iss :gitlab-oauth}) - info (some->> (get-in request [:params :code]) - (get-access-token cfg) - (get-user-info cfg))] - (when-not info - (ex/raise :type :internal - :code :unable-to-auth)) - - (cond-> info - (some? (:invitation-token state)) - (assoc :invitation-token (:invitation-token state))))) - - -(defn- auth-handler - [{:keys [tokens] :as cfg} request] - (let [invitation (get-in request [:params :invitation-token]) - state (tokens :generate - {:iss :gitlab-oauth - :invitation-token invitation - :exp (dt/in-future "15m")}) - - params {:client_id (:client-id cfg) - :redirect_uri (build-redirect-url cfg) - :response_type "code" - :state state - :scope scope} - query (u/map->query-string params) - uri (-> (build-oauth-uri cfg) - (assoc :query query))] - {:status 200 - :body {:redirect-uri (str uri)}})) - -(defn- callback-handler - [{:keys [session] :as cfg} request] - (try - (let [info (retrieve-info cfg request) - profile (gg/register-profile cfg info) - uri (gg/generate-redirect-uri cfg profile) - sxf ((:create session) (:id profile))] - (->> (gg/redirect-response uri) - (sxf request))) - (catch Exception _e - (-> (gg/generate-error-redirect-uri cfg) - (gg/redirect-response))))) - -(s/def ::client-id ::us/not-empty-string) -(s/def ::client-secret ::us/not-empty-string) -(s/def ::base-uri ::us/not-empty-string) -(s/def ::public-uri ::us/not-empty-string) -(s/def ::session map?) -(s/def ::tokens fn?) - -(defmethod ig/pre-init-spec :app.http.oauth/gitlab [_] - (s/keys :req-un [::public-uri - ::session - ::tokens] - :opt-un [::base-uri - ::client-id - ::client-secret])) - -(defmethod ig/prep-key :app.http.oauth/gitlab - [_ cfg] - (d/merge {:base-uri "https://gitlab.com"} - (d/without-nils cfg))) - -(defn- default-handler - [_] - (ex/raise :type :not-found)) - -(defmethod ig/init-key :app.http.oauth/gitlab - [_ cfg] - (if (and (:client-id cfg) - (:client-secret cfg)) - {:handler #(auth-handler cfg %) - :callback-handler #(callback-handler cfg %)} - {:handler default-handler - :callback-handler default-handler})) diff --git a/backend/src/app/http/oauth/google.clj b/backend/src/app/http/oauth/google.clj deleted file mode 100644 index b671079a6..000000000 --- a/backend/src/app/http/oauth/google.clj +++ /dev/null @@ -1,181 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) UXBOX Labs SL - -(ns app.http.oauth.google - (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.util.http :as http] - [app.util.logging :as l] - [app.util.time :as dt] - [clojure.data.json :as json] - [clojure.spec.alpha :as s] - [integrant.core :as ig] - [lambdaisland.uri :as u])) - -(def base-goauth-uri "https://accounts.google.com/o/oauth2/v2/auth") - -(def scope - (str "email profile " - "https://www.googleapis.com/auth/userinfo.email " - "https://www.googleapis.com/auth/userinfo.profile " - "openid")) - -(defn- build-redirect-url - [cfg] - (let [public (u/uri (:public-uri cfg))] - (str (assoc public :path "/api/oauth/google/callback")))) - -(defn- get-access-token - [cfg code] - (try - (let [params {:code code - :client_id (:client-id cfg) - :client_secret (:client-secret cfg) - :redirect_uri (build-redirect-url cfg) - :grant_type "authorization_code"} - req {:method :post - :headers {"content-type" "application/x-www-form-urlencoded"} - :uri "https://oauth2.googleapis.com/token" - :timeout 6000 - :body (u/map->query-string params)} - res (http/send! req)] - - (when (= 200 (:status res)) - (-> (json/read-str (:body res)) - (get "access_token")))) - (catch Exception e - (l/error :hint "unexpected error on get-access-token" - :cause e) - nil))) - -(defn- get-user-info - [_ token] - (try - (let [req {:uri "https://openidconnect.googleapis.com/v1/userinfo" - :headers {"Authorization" (str "Bearer " token)} - :timeout 6000 - :method :get} - res (http/send! req)] - - (when (= 200 (:status res)) - (let [data (json/read-str (:body res))] - {:email (get data "email") - :backend "google" - :fullname (get data "name")}))) - (catch Exception e - (l/error :hint "unexpected exception on get-user-info" - :cause e) - nil))) - -(defn- retrieve-info - [{:keys [tokens] :as cfg} request] - (let [token (get-in request [:params :state]) - state (tokens :verify {:token token :iss :google-oauth}) - info (some->> (get-in request [:params :code]) - (get-access-token cfg) - (get-user-info cfg))] - - - (when-not info - (ex/raise :type :internal - :code :unable-to-auth)) - - (cond-> info - (some? (:invitation-token state)) - (assoc :invitation-token (:invitation-token state))))) - -(defn register-profile - [{:keys [rpc] :as cfg} info] - (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) - profile (method-fn {:email (:email info) - :backend (:backend info) - :fullname (:fullname info)})] - (cond-> profile - (some? (:invitation-token info)) - (assoc :invitation-token (:invitation-token info))))) - -(defn generate-redirect-uri - [{:keys [tokens] :as cfg} profile] - (let [token (or (:invitation-token profile) - (tokens :generate {:iss :auth - :exp (dt/in-future "15m") - :profile-id (:id profile)}))] - (-> (u/uri (:public-uri cfg)) - (assoc :path "/#/auth/verify-token") - (assoc :query (u/map->query-string {:token token}))))) - -(defn generate-error-redirect-uri - [cfg] - (-> (u/uri (:public-uri cfg)) - (assoc :path "/#/auth/login") - (assoc :query (u/map->query-string {:error "unable-to-auth"})))) - -(defn redirect-response - [uri] - {:status 302 - :headers {"location" (str uri)} - :body ""}) - -(defn- auth-handler - [{:keys [tokens] :as cfg} request] - (let [invitation (get-in request [:params :invitation-token]) - state (tokens :generate - {:iss :google-oauth - :invitation-token invitation - :exp (dt/in-future "15m")}) - params {:scope scope - :access_type "offline" - :include_granted_scopes true - :state state - :response_type "code" - :redirect_uri (build-redirect-url cfg) - :client_id (:client-id cfg)} - query (u/map->query-string params) - uri (-> (u/uri base-goauth-uri) - (assoc :query query))] - - {:status 200 - :body {:redirect-uri (str uri)}})) - -(defn- callback-handler - [{:keys [session] :as cfg} request] - (try - (let [info (retrieve-info cfg request) - profile (register-profile cfg info) - uri (generate-redirect-uri cfg profile) - sxf ((:create session) (:id profile))] - (->> (redirect-response uri) - (sxf request))) - (catch Exception _e - (-> (generate-error-redirect-uri cfg) - (redirect-response))))) - -(s/def ::client-id ::us/not-empty-string) -(s/def ::client-secret ::us/not-empty-string) -(s/def ::public-uri ::us/not-empty-string) -(s/def ::session map?) -(s/def ::tokens fn?) - -(defmethod ig/pre-init-spec :app.http.oauth/google [_] - (s/keys :req-un [::public-uri - ::session - ::tokens] - :opt-un [::client-id - ::client-secret])) - -(defn- default-handler - [_] - (ex/raise :type :not-found)) - -(defmethod ig/init-key :app.http.oauth/google - [_ cfg] - (if (and (:client-id cfg) - (:client-secret cfg)) - {:handler #(auth-handler cfg %) - :callback-handler #(callback-handler cfg %)} - {:handler default-handler - :callback-handler default-handler})) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index fcf889b94..e40946e14 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -86,13 +86,12 @@ :ws {"/ws/notifications" (ig/ref :app.notifications/handler)}} :app.http/router - { - :rpc (ig/ref :app.rpc/rpc) + {:rpc (ig/ref :app.rpc/rpc) :session (ig/ref :app.http.session/session) :tokens (ig/ref :app.tokens/tokens) :public-uri (cf/get :public-uri) :metrics (ig/ref :app.metrics/metrics) - :oauth (ig/ref :app.http.oauth/all) + :oauth (ig/ref :app.http.oauth/handlers) :assets (ig/ref :app.http.assets/handlers) :storage (ig/ref :app.storage/storage) :sns-webhook (ig/ref :app.http.awsns/handler) @@ -109,35 +108,11 @@ :app.http.feedback/handler {:pool (ig/ref :app.db/pool)} - :app.http.oauth/all - {:google (ig/ref :app.http.oauth/google) - :gitlab (ig/ref :app.http.oauth/gitlab) - :github (ig/ref :app.http.oauth/github)} - - :app.http.oauth/google + :app.http.oauth/handlers {:rpc (ig/ref :app.rpc/rpc) :session (ig/ref :app.http.session/session) :tokens (ig/ref :app.tokens/tokens) - :public-uri (cf/get :public-uri) - :client-id (cf/get :google-client-id) - :client-secret (cf/get :google-client-secret)} - - :app.http.oauth/github - {:rpc (ig/ref :app.rpc/rpc) - :session (ig/ref :app.http.session/session) - :tokens (ig/ref :app.tokens/tokens) - :public-uri (cf/get :public-uri) - :client-id (cf/get :github-client-id) - :client-secret (cf/get :github-client-secret)} - - :app.http.oauth/gitlab - {:rpc (ig/ref :app.rpc/rpc) - :session (ig/ref :app.http.session/session) - :tokens (ig/ref :app.tokens/tokens) - :public-uri (cf/get :public-uri) - :base-uri (cf/get :gitlab-base-uri) - :client-id (cf/get :gitlab-client-id) - :client-secret (cf/get :gitlab-client-secret)} + :public-uri (cf/get :public-uri)} ;; RLimit definition for password hashing :app.rlimits/password diff --git a/docker/images/files/config.js b/docker/images/files/config.js index 0ac491bbb..aac4fb709 100644 --- a/docker/images/files/config.js +++ b/docker/images/files/config.js @@ -6,5 +6,6 @@ //var penpotGoogleClientID = ""; //var penpotGitlabClientID = ""; //var penpotGithubClientID = ""; +//var penpotOIDCClientID = ""; //var penpotLoginWithLDAP = ; //var penpotRegistrationEnabled = ; diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index e99e69787..b341649a7 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -69,6 +69,14 @@ update_github_client_id() { fi } +update_oidc_client_id() { + if [ -n "$PENPOT_OIDC_CLIENT_ID" ]; then + log "Updating Oidc Client Id: $PENPOT_OIDC_CLIENT_ID" + sed -i \ + -e "s|^//var penpotOIDCClientID = \".*\";|var penpotOIDCClientID = \"$PENPOT_OIDC_CLIENT_ID\";|g" \ + "$1" + fi +} update_login_with_ldap() { if [ -n "$PENPOT_LOGIN_WITH_LDAP" ]; then @@ -95,6 +103,7 @@ update_allow_demo_users /var/www/app/js/config.js update_google_client_id /var/www/app/js/config.js update_gitlab_client_id /var/www/app/js/config.js update_github_client_id /var/www/app/js/config.js +update_oidc_client_id /var/www/app/js/config.js update_login_with_ldap /var/www/app/js/config.js update_registration_enabled /var/www/app/js/config.js diff --git a/frontend/resources/styles/main/layouts/login.scss b/frontend/resources/styles/main/layouts/login.scss index 03de4ceaa..13be87951 100644 --- a/frontend/resources/styles/main/layouts/login.scss +++ b/frontend/resources/styles/main/layouts/login.scss @@ -57,7 +57,7 @@ .form-container { width: 412px; - .btn-ocean { + .auth-buttons { margin-top: $x-big; } diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 17f67993f..1542f45a7 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -69,6 +69,7 @@ (def google-client-id (obj/get global "penpotGoogleClientID" nil)) (def gitlab-client-id (obj/get global "penpotGitlabClientID" nil)) (def github-client-id (obj/get global "penpotGithubClientID" nil)) +(def oidc-client-id (obj/get global "penpotOIDCClientID" nil)) (def login-with-ldap (obj/get global "penpotLoginWithLDAP" false)) (def registration-enabled (obj/get global "penpotRegistrationEnabled" true)) (def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js")) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index b4ec717bd..ef9050268 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -6,6 +6,7 @@ (ns app.main.repo (:require + [app.common.data :as d] [beicon.core :as rx] [lambdaisland.uri :as u] [cuerdas.core :as str] @@ -84,23 +85,10 @@ ([id] (mutation id {})) ([id params] (mutation id params))) -(defmethod mutation :login-with-google - [id params] - (let [uri (u/join base-uri "api/oauth/google")] - (->> (http/send! {:method :post :uri uri :query params}) - (rx/map http/conditional-decode-transit) - (rx/mapcat handle-response)))) - -(defmethod mutation :login-with-gitlab - [id params] - (let [uri (u/join base-uri "api/oauth/gitlab")] - (->> (http/send! {:method :post :uri uri :query params}) - (rx/map http/conditional-decode-transit) - (rx/mapcat handle-response)))) - -(defmethod mutation :login-with-github - [id params] - (let [uri (u/join base-uri "api/oauth/github")] +(defmethod mutation :login-with-oauth + [id {:keys [provider] :as params}] + (let [uri (u/join base-uri "api/auth/oauth/" (d/name provider)) + params (dissoc params :provider)] (->> (http/send! {:method :post :uri uri :query params}) (rx/map http/conditional-decode-transit) (rx/mapcat handle-response)))) diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index 973ef6c41..5dd40cecc 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -29,26 +29,10 @@ (s/def ::login-form (s/keys :req-un [::email ::password])) -(defn- login-with-google - [event params] +(defn- login-with-oauth + [event provider params] (dom/prevent-default event) - (->> (rp/mutation! :login-with-google params) - (rx/subs (fn [{:keys [redirect-uri] :as rsp}] - (.replace js/location redirect-uri)) - (fn [{:keys [type] :as error}] - (st/emit! (dm/error (tr "errors.google-auth-not-enabled"))))))) - -(defn- login-with-gitlab - [event params] - (dom/prevent-default event) - (->> (rp/mutation! :login-with-gitlab params) - (rx/subs (fn [{:keys [redirect-uri] :as rsp}] - (.replace js/location redirect-uri))))) - -(defn- login-with-github - [event params] - (dom/prevent-default event) - (->> (rp/mutation! :login-with-github params) + (->> (rp/mutation! :login-with-oauth (assoc params :provider provider)) (rx/subs (fn [{:keys [redirect-uri] :as rsp}] (.replace js/location redirect-uri))))) @@ -127,6 +111,33 @@ {:label (tr "auth.login-with-ldap-submit") :on-click on-submit-ldap}])]])) +(mf/defc login-buttons + [{:keys [params] :as props}] + [:div.auth-buttons + (when cfg/google-client-id + [:a.btn-ocean.btn-large.btn-google-auth + {:on-click #(login-with-oauth % :google params)} + (tr "auth.login-with-google-submit")]) + + (when cfg/gitlab-client-id + [:a.btn-ocean.btn-large.btn-gitlab-auth + {:on-click #(login-with-oauth % :gitlab params)} + [:img.logo + {:src "/images/icons/brand-gitlab.svg"}] + (tr "auth.login-with-gitlab-submit")]) + + (when cfg/github-client-id + [:a.btn-ocean.btn-large.btn-github-auth + {:on-click #(login-with-oauth % :github params)} + [:img.logo + {:src "/images/icons/brand-github.svg"}] + (tr "auth.login-with-github-submit")]) + + (when cfg/oidc-client-id + [:a.btn-ocean.btn-large.btn-github-auth + {:on-click #(login-with-oauth % :oidc params)} + (tr "auth.login-with-oidc-submit")])]) + (mf/defc login-page [{:keys [params] :as props}] [:div.generic-form.login-form @@ -149,24 +160,7 @@ :tab-index "6"} (tr "auth.register-submit")]])] - (when cfg/google-client-id - [:a.btn-ocean.btn-large.btn-google-auth - {:on-click #(login-with-google % params)} - "Login with Google"]) - - (when cfg/gitlab-client-id - [:a.btn-ocean.btn-large.btn-gitlab-auth - {:on-click #(login-with-gitlab % params)} - [:img.logo - {:src "/images/icons/brand-gitlab.svg"}] - (tr "auth.login-with-gitlab-submit")]) - - (when cfg/github-client-id - [:a.btn-ocean.btn-large.btn-github-auth - {:on-click #(login-with-github % params)} - [:img.logo - {:src "/images/icons/brand-github.svg"}] - (tr "auth.login-with-github-submit")]) + [:& login-buttons {:params params}] (when cfg/allow-demo-users [:div.links.demo diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 34cbfae1f..a8321a6a0 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -137,7 +137,6 @@ [:div.notification-text-email (:email params "")] [:div.notification-text (tr "auth.check-your-email")]]) - (mf/defc register-page [{:keys [params] :as props}] [:div.form-container @@ -161,24 +160,9 @@ [:span (tr "auth.create-demo-profile") " "] [:a {:on-click #(st/emit! da/create-demo-profile) :tab-index "5"} - (tr "auth.create-demo-account")]])] + (tr "auth.create-demo-account")]]) - (when cfg/google-client-id - [:a.btn-ocean.btn-large.btn-google-auth - {:on-click #(login/login-with-google % params)} - "Login with Google"]) + [:& login/login-buttons {:params params}]]]) - (when cfg/gitlab-client-id - [:a.btn-ocean.btn-large.btn-gitlab-auth - {:on-click #(login/login-with-gitlab % params)} - [:img.logo - {:src "/images/icons/brand-gitlab.svg"}] - (tr "auth.login-with-gitlab-submit")]) - (when cfg/github-client-id - [:a.btn-ocean.btn-large.btn-github-auth - {:on-click #(login/login-with-github % params)} - [:img.logo - {:src "/images/icons/brand-github.svg"}] - (tr "auth.login-with-github-submit")])]) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 26cab1a7f..a47d70664 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -64,18 +64,26 @@ msgstr "Enter your details below" msgid "auth.login-title" msgstr "Great to see you again!" -#: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs +#: src/app/main/ui/auth/login.cljs msgid "auth.login-with-github-submit" msgstr "Login with Github" -#: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs +#: src/app/main/ui/auth/login.cljs msgid "auth.login-with-gitlab-submit" msgstr "Login with Gitlab" +#: src/app/main/ui/auth/login.cljs +msgid "auth.login-with-google-submit" +msgstr "Login with Google" + #: src/app/main/ui/auth/login.cljs msgid "auth.login-with-ldap-submit" msgstr "Sign in with LDAP" +#: src/app/main/ui/auth/login.cljs +msgid "auth.login-with-oidc-submit" +msgstr "Login with OpenID (SSO)" + #: src/app/main/ui/auth/recovery.cljs msgid "auth.new-password" msgstr "Type a new password" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 31911b1f0..b10462477 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -60,18 +60,26 @@ msgstr "Introduce tus datos aquƭ" msgid "auth.login-title" msgstr "Encantados de volverte a ver" -#: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs +#: src/app/main/ui/auth/login.cljs msgid "auth.login-with-github-submit" msgstr "Entrar con Github" -#: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs +#: src/app/main/ui/auth/login.cljs msgid "auth.login-with-gitlab-submit" msgstr "Entrar con Gitlab" +#: src/app/main/ui/auth/login.cljs +msgid "auth.login-with-google-submit" +msgstr "Entrar con Google" + #: src/app/main/ui/auth/login.cljs msgid "auth.login-with-ldap-submit" msgstr "Entrar con LDAP" +#: src/app/main/ui/auth/login.cljs +msgid "auth.login-with-oidc-submit" +msgstr "Entrar con OpenID (SSO)" + #: src/app/main/ui/auth/recovery.cljs msgid "auth.new-password" msgstr "Introduce la nueva contraseƱa"