diff --git a/.gitignore b/.gitignore index e64e42b..7e38169 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ docker_build/ # Pyre type checker .pyre/ env +.idea diff --git a/Dockerfile b/Dockerfile index fcdd87f..44b52a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ # SOFTWARE. # # Author Komal Thareja (kthare10@renci.org) -FROM python:3 +FROM python:3.11 MAINTAINER Komal Thareja RUN mkdir -p /usr/src/app diff --git a/cm-app/src/App.js b/cm-app/src/App.js index 4bfe4c4..cdd42f7 100644 --- a/cm-app/src/App.js +++ b/cm-app/src/App.js @@ -4,13 +4,18 @@ import Header from "./components/Header"; import Home from './pages/Home'; import CredentialManagerPage from './pages/CredentialManagerPage'; import Footer from "./components/Footer"; -import "./styles/App.scss"; import { getWhoAmI } from "./services/coreApiService.js"; -import { toast } from "react-toastify"; +import { toast, ToastContainer } from "react-toastify"; +import SessionTimeoutModal from "./components/Modals/SessionTimeoutModal"; +import { default as cmData } from "./services/cmData.json"; +import 'react-toastify/dist/ReactToastify.css'; +import "./styles/App.scss"; class App extends React.Component { state = { - cmUserStatus: "" + cmUserStatus: "", + showSessionTimeoutModal1: false, + showSessionTimeoutModal2: false, } async componentDidMount() { @@ -21,6 +26,25 @@ class App extends React.Component { if (user.enrolled) { localStorage.setItem("cmUserID", user.uuid); localStorage.setItem("cmUserStatus", "active"); + try { + // after user logs in for 3hr55min, pop up first session time-out modal + const sessionTimeoutInterval1 = setInterval(() => + this.setState({showSessionTimeoutModal1: true}) + , cmData["5minBeforeCookieExpires"]); + + // after user logs in for 3hr59min, pop up second session time-out modal + const sessionTimeoutInterval2 = setInterval(() => { + this.setState({ + showSessionTimeoutModal1: false, + showSessionTimeoutModal2: true, + }) + }, cmData["1minBeforeCookieExpires"]); + + localStorage.setItem("sessionTimeoutInterval1", sessionTimeoutInterval1); + localStorage.setItem("sessionTimeoutInterval2", sessionTimeoutInterval2); + } catch (err) { + console.log("Failed to get current user's information."); + } } else { toast.error("Please enroll to FABRIC in the Portal first."); } @@ -33,12 +57,26 @@ class App extends React.Component { } render() { - const { cmUserStatus } = this.state; + const { cmUserStatus, showSessionTimeoutModal1, showSessionTimeoutModal2 } = this.state; return (
+ { + showSessionTimeoutModal1 && + + } + { + showSessionTimeoutModal2 && + + } } /> } /> @@ -46,6 +84,18 @@ class App extends React.Component { } />
+
); diff --git a/cm-app/src/components/Modals/SessionTimeoutModal.jsx b/cm-app/src/components/Modals/SessionTimeoutModal.jsx new file mode 100644 index 0000000..0b3d670 --- /dev/null +++ b/cm-app/src/components/Modals/SessionTimeoutModal.jsx @@ -0,0 +1,100 @@ +import React, { Component } from "react"; +import Modal from 'react-bootstrap/Modal' +import Button from 'react-bootstrap/Button' +import clearLocalStorage from "../../utils/clearLocalStorage"; + +class SessionTimeoutModal extends Component { + state = { + show: true, + minutes: 0, + seconds: 0, + } + + handleLogout = () => { + this.setState({ show: false }); + clearLocalStorage(); + window.location.href = "/logout"; + } + + handleClose = () => { + this.setState({ show: false }); + } + + componentDidMount() { + let minutes = Math.floor(this.props.timeLeft / 60000); + let seconds = ((this.props.timeLeft % 60000) / 1000).toFixed(0); + this.setState({ minutes, seconds }) + + let countdownTimer = setInterval(() => { + if(seconds > 0){ + seconds--; + } else if (minutes > 0){ + minutes--; + seconds = 59; + } else { + minutes = 0; + seconds = 0; + } + this.setState({ minutes, seconds }) + }, 1000); + + localStorage.setItem("countdownTimerIntervalId", countdownTimer); + } + + parseTimeStr = (minutes, seconds) => { + if (minutes > 0 && seconds > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} ${seconds} second${seconds > 1 ? "s" : ""}`; + } + + if (minutes > 0 && seconds === 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""}`; + } + + if (minutes === 0 && seconds > 1) { + return `${seconds} second${seconds > 1 ? "s" : ""}`; + } + + if (minutes === 0 && seconds === 1) { + clearInterval(localStorage.getItem("countdownTimerIntervalId")); + clearInterval(localStorage.getItem(`sessionTimeoutIntervalId${this.props.modalId}`)); + this.handleLogout(); + } + } + + render() { + let { minutes, seconds, show } = this.state; + return ( +
+ { + this.props.timeLeft > 0 && + + Session Timeout + + +

+ The current session is about to expire in + {this.parseTimeStr(minutes, seconds)}. + Please save your work to prevent loss of data. +

+
+ + + + +
+ + } +
+ ); + } +} + +export default SessionTimeoutModal; \ No newline at end of file diff --git a/cm-app/src/components/SpinnerFullPage.jsx b/cm-app/src/components/SpinnerFullPage.jsx new file mode 100644 index 0000000..682cee7 --- /dev/null +++ b/cm-app/src/components/SpinnerFullPage.jsx @@ -0,0 +1,61 @@ +import React, { useRef } from "react"; +import Spinner from 'react-bootstrap/Spinner'; +import Overlay from 'react-bootstrap/Overlay'; +import Button from 'react-bootstrap/Button'; +import { Link } from "react-router-dom"; + +function SpinnerFullPage(props){ + const target = useRef(null); + const {text, showSpinner, btnText, btnPath} = props; + + return ( +
+ + + {({ placement, arrowProps, show: _show, popper, ...props }) => ( +
+
+ {text} + +
+ { + btnText && btnText !== "" && + + { btnText } + + } +
+ )} +
+
+ ); +}; + +export default SpinnerFullPage; \ No newline at end of file diff --git a/cm-app/src/components/SpinnerWithText.jsx b/cm-app/src/components/SpinnerWithText.jsx new file mode 100644 index 0000000..4f973b0 --- /dev/null +++ b/cm-app/src/components/SpinnerWithText.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import Spinner from 'react-bootstrap/Spinner'; + +const SpinnerWithText = (props) => { + return ( +
+
+ ); +}; + +export default SpinnerWithText; diff --git a/cm-app/src/pages/CredentialManagerPage.jsx b/cm-app/src/pages/CredentialManagerPage.jsx index e045227..bd984c1 100644 --- a/cm-app/src/pages/CredentialManagerPage.jsx +++ b/cm-app/src/pages/CredentialManagerPage.jsx @@ -4,7 +4,10 @@ import Card from 'react-bootstrap/Card'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import Alert from 'react-bootstrap/Alert'; -import { createIdToken, refreshToken, revokeToken } from "../services/credentialManagerService.js"; +import Badge from 'react-bootstrap/Badge'; +import SpinnerFullPage from "../components/SpinnerFullPage.jsx"; +import toLocaleTime from "../utils/toLocaleTime"; +import { createIdToken, revokeToken, getTokenByProjectId, validateToken } from "../services/credentialManagerService.js"; import { getProjects } from "../services/coreApiService.js"; import { default as externalLinks } from "../services/externalLinks.json"; import checkCmAppType from "../utils/checkCmAppType"; @@ -14,21 +17,29 @@ class CredentialManagerPage extends React.Component { state = { projects: [], createToken: "", - refreshToken: "", + tokenList: [], createSuccess: false, createCopySuccess: false, - refreshSuccess: false, - refreshCopySuccess: false, - revokeSuccess: false, + listSuccess: false, scopeOptions: [ { id: 1, value: "all", display: "All"}, { id: 2, value: "cf", display: "Control Framework"}, { id: 3, value: "mf", display: "Measurement Framework"}, ], selectedCreateScope: "all", - selectedRefreshScope: "all", - selectedCreateProject: "", - selectedRefreshProject: "", + selectedProjectId: "", + isTokenHolder: false, + validateTokenValue: "", + isTokenValid: false, + validateSuccess: false, + revokeIdentitySuccess: false, + decodedToken: "", + tokenMsg: "", + inputLifetime: 4, // Default lifetime is 4 hours + selectLifetimeUnit: "hours", + tokenComment: "Created via GUI", // Added comment for creating tokens, + showFullPageSpinner: false, + spinnerMessage: "" } portalLinkMap = { @@ -37,59 +48,99 @@ class CredentialManagerPage extends React.Component { "production": externalLinks.portalLinkProduction, } - async componentDidMount(){ - try { - const { data: res } = await getProjects(localStorage.getItem("cmUserID")); - const projects = res.results; - this.setState({ projects }); - if (projects.length > 0) { - this.setState({ selectedCreateProject: projects[0].uuid, selectedRefreshProject: projects[0].uuid }); + async componentDidMount() { + try { + const { data: res } = await getProjects(localStorage.getItem("cmUserID")); + const projects = res.results; + this.setState({ projects }); + if (projects.length > 0) { + this.setState({ + selectedProjectId: projects[0].uuid, + isTokenHolder: projects[0].memberships.is_token_holder + }, () => { + this.listTokens(); + }); + } + } catch (ex) { + toast.error("Failed to load user's project information. Please reload this page."); } - } catch (ex) { - toast.error("Failed to load user's project information. Please reload this page."); - } + } + + parseTokenLifetime = ()=> { + const { inputLifetime: time, selectLifetimeUnit: unit } = this.state; + if (unit === "hours") { return parseInt(time); } + if (unit === "days") { return parseInt(time) * 24; } + if (unit === "weeks") { return parseInt(time) * 24 * 7; } } createToken = async (e) => { e.preventDefault(); - + this.setState({ showFullPageSpinner: true, spinnerMessage: "Creating Token..."}); try { - const project = this.state.selectedCreateProject; + const projectId = this.state.selectedProjectId; const scope = this.state.selectedCreateScope; - const { data: res } = await createIdToken(project, scope); - this.setState({ createCopySuccess: false, createSuccess: true }); - this.setState({ createToken: JSON.stringify(res["data"][0], undefined, 4) }); + const lifetime = this.parseTokenLifetime(); // Added lifetime parameter + const comment = this.state.tokenComment; // Added comment for the token + const { data: res } = await createIdToken(projectId, scope, lifetime, comment); + console.log("Response received: " + res) + this.setState({ + createCopySuccess: false, + createSuccess: true, + createToken: JSON.stringify(res["data"][0], undefined, 4), + showFullPageSpinner: false, + spinnerMessage: "", + selectedProjectId: projectId + }, () => { + this.listTokens(); + }); + + toast.success("Token created successfully."); } catch (ex) { + this.setState({ showFullPageSpinner: false, spinnerMessage: ""}); toast.error("Failed to create token."); } } - refreshToken = async (e) => { + revokeIdentityToken = async (e, tokenHash) => { e.preventDefault(); try { - const project = this.state.selectedRefreshProject; - const scope = this.state.selectedRefreshScope; - const { data: res } = await refreshToken(project, scope, document.getElementById('refreshTokenTextArea').value); - this.setState({ refreshCopySuccess: false, refreshSuccess: true }); - this.setState({ refreshToken: JSON.stringify(res["data"][0], undefined, 4) }); + const { data: res } = await revokeToken("identity", tokenHash); + this.setState({ + revokeIdentitySuccess: true, + revokedTokenHash: tokenHash + }, () => { + this.listTokens(); + }); + + toast.success("Token revoked successfully."); } catch (ex) { - this.setState({ refreshSuccess: false }); - toast.error("Failed to refresh token.") + this.setState({ revokeIdentitySuccess: false, revokedTokenHash: "" }); + toast.error("Failed to revoke token.") } } - revokeToken = async (e) => { + listTokens = async () => { + try { + const projectId = this.state.selectedProjectId; + const res = await getTokenByProjectId(projectId); // Assuming getTokenByProjectId returns an array of tokens + this.setState({ listSuccess: true, tokenList: res.data.data }); + } catch (ex) { + toast.error("Failed to get tokens."); + } + } + + validateToken = async (e) => { e.preventDefault(); try { - await revokeToken(document.getElementById('revokeTokenTextArea').value); - this.setState({ revokeSuccess: true }); + const { data: res } = await validateToken(this.state.validateTokenValue); + this.setState({ validateSuccess: true, isTokenValid: true, decodedToken: res.token, tokenMsg: "Token is validated." }); } catch (ex) { - this.setState({ revokeSuccess: false }); - toast.error("Failed to revoke token.") + this.setState({ validateSuccess: true, isTokenValid: false, decodedToken: "" }); + toast.error("Failed to validate token.") } } @@ -116,52 +167,91 @@ class CredentialManagerPage extends React.Component { element.click(); } - handleSelectCreateProject = (e) =>{ - this.setState({ selectedCreateProject: e.target.value }); + handleSelectProject = (e) =>{ + const project = this.state.projects.filter(p => p.uuid === e.target.value)[0]; + // change selected project, hide any created token from UI and reset options + this.setState({ + selectedProjectId: project.uuid, + isTokenHolder: project.memberships.is_token_holder, + createSuccess: false, + createCopySuccess: false, + inputLifetime: 4, + selectLifetimeUnit: "hours", + selectedCreateScope: "all", + tokenComment: "Created via GUI" + }, () => { + this.listTokens(); + }); } - handleSelectRefreshProject = (e) =>{ - this.setState({ selectedRefreshProject: e.target.value }); + handleSelectCreateScope = (e) =>{ + this.setState({ selectedCreateScope: e.target.value }); } + handleLifetimeChange = (e) => { + this.setState({ inputLifetime: parseInt(e.target.value) }); + }; - handleSelectCreateScope = (e) =>{ - this.setState({ selectedCreateScope: e.target.value }); + handleLifetimeUnitChange = (e) => { + this.setState({ selectLifetimeUnit: e.target.value }); } - handleSelectRefreshScope = (e) =>{ - this.setState({ selectedRefreshScope: e.target.value }); + handleCommentChange = (e) => { + this.setState({ tokenComment: e.target.value }); + }; + + getTokenStateClasses = (state) => { + if (state === "Revoked" || state === "Expired") { + return "danger"; + } else if (state === "Valid" || state === "Refreshed") { + return "success"; + } else { + return "primary"; + } } render() { - const { projects, scopeOptions, createSuccess, createToken, - createCopySuccess, refreshToken, refreshSuccess, refreshCopySuccess, revokeSuccess } = this.state; - + const { projects, scopeOptions, createSuccess, createToken, createCopySuccess, inputLifetime, selectedProjectId, + selectLifetimeUnit, selectedCreateScope, listSuccess, tokenList, decodedToken, tokenMsg, validateTokenValue, + isTokenValid, validateSuccess, tokenComment, showFullPageSpinner, spinnerMessage, + isTokenHolder } = this.state; + const portalLink = this.portalLinkMap[checkCmAppType()]; + if (showFullPageSpinner) { + return ( +
+ +
+ ) + } + return (
- { + { projects.length === 0 &&

To manage tokens, you have to be in a project first:

} - { + { projects.length > 0 &&
-
+
Please consult     for obtaining and using FABRIC API tokens.
-

Create Token

+

Create and List Tokens

+
+ + + + Select Project + + { + projects.length > 0 && projects.map(project => { + return ( + + ) + }) + } + + + + +
+
+ { + !isTokenHolder ? + + The default token lifetime is 4 hours. To obtain + long-lived tokens + for the selected project, please request access from FABRIC Portal. + : + + You have access to + long-lived tokens for this project. The lifetime limit is 9 weeks. + + } +
- + - Select Project - - { - projects.length > 0 && projects.map(project => { - return ( - - ) - }) - } - + + Lifetime + + - + + + Unit + + + + + + + + + + Comment (10 - 100 characters) + + + + Select Scope - - { + + { scopeOptions.map(option => { return ( - - - +
+ { + listSuccess && tokenList.length > 0 ? + + + + + + + + + + + + + + { + tokenList.map((token, index) => ( + + + + + + + + + + )) + } + +
Token HashCommentCreated AtExpires AtStateFromActions
{token['token_hash']}{token['comment']}{toLocaleTime(token['created_at'])}{toLocaleTime(token['expires_at'])} + + {token['state']} + + {token['created_from']} + { + token['state'] !== "Revoked" && + + } +
+ : +
+ No tokens available for the selected project. +
+ } +
+

Validate Identity Token

+ + + Paste the token to validate: + + + + this.setState({ validateTokenValue: e.target.value, isTokenValid: false })} + /> + + + + {validateSuccess && isTokenValid && ( + <> + + {tokenMsg} + + Decoded Token: this.textArea = textarea} as="textarea" - id="refreshTokenTextArea" - defaultValue={refreshToken} rows={6} + value={JSON.stringify(decodedToken, undefined, 4) } + readOnly /> -
- + )} - { - refreshCopySuccess && ( - - Copied to clipboard successfully! + {validateSuccess && !isTokenValid && validateTokenValue !== '' && ( + + Token is invalid! )} - - { - !refreshSuccess && ( - - ) - } -

Revoke Token

- - - Paste the refresh token to revoke: - - - - - - - - {revokeSuccess && ( - - The token is revoked successfully! - - )} -
+
}
) diff --git a/cm-app/src/services/cmData.json b/cm-app/src/services/cmData.json new file mode 100644 index 0000000..b49f73d --- /dev/null +++ b/cm-app/src/services/cmData.json @@ -0,0 +1,4 @@ +{ + "5minBeforeCookieExpires": 14100000, + "1minBeforeCookieExpires": 14340000 +} \ No newline at end of file diff --git a/cm-app/src/services/credentialManagerService.js b/cm-app/src/services/credentialManagerService.js index 1fd4484..36458a8 100644 --- a/cm-app/src/services/credentialManagerService.js +++ b/cm-app/src/services/credentialManagerService.js @@ -4,8 +4,8 @@ import checkCmAppType from "../utils/checkCmAppType"; const apiEndpoint = config.credentialManagerApiUrl[checkCmAppType()]; -export function createIdToken(projectId, scope) { - return http.post(apiEndpoint + "/create?project_id=" + projectId + "&scope=" + scope); +export function createIdToken(projectId, scope, lifetime, comment) { + return http.post(apiEndpoint + "/create?project_id=" + projectId + "&scope=" + scope + "&lifetime=" + lifetime + "&comment=" + comment) } export function refreshToken(projectId, scope, refresh_token) { @@ -15,9 +15,34 @@ export function refreshToken(projectId, scope, refresh_token) { return http.post(apiEndpoint + "/refresh?project_id=" + projectId + "&scope=" + scope, data); } -export function revokeToken(refresh_token) { +export function revokeToken(token_type, token) { const data = { - "refresh_token": refresh_token + "token": token, + "type": token_type + } + return http.post(apiEndpoint + "/revokes", data); +} + +export function tokenRevokeList(projectId) { + return http.get(apiEndpoint + "/revoke_list?project_id=" + projectId); +} + +export function getTokenByHash(tokenHash) { + return http.get(apiEndpoint + "?token_hash=" + tokenHash + "&limit=200&offset=0"); +} + +export function getTokenByProjectId(projectId) { + return http.get(apiEndpoint + "?project_id=" + projectId + "&limit=200&offset=0") +} + +export function getTokens() { + return http.get(apiEndpoint + "?limit=200&offset=0"); +} + +export function validateToken(token) { + const data = { + "token": token, + "type": "identity" } - return http.post(apiEndpoint + "/revoke", data); + return http.post(apiEndpoint + "/validate", data); } \ No newline at end of file diff --git a/cm-app/src/services/externalLinks.json b/cm-app/src/services/externalLinks.json index 37ac919..111d52a 100644 --- a/cm-app/src/services/externalLinks.json +++ b/cm-app/src/services/externalLinks.json @@ -3,5 +3,6 @@ "portalLinkBeta": "https://beta-4.fabric-testbed.net/", "portalLinkProduction": "https://portal.fabric-testbed.net/", "learnArticleFabricTokens": "https://learn.fabric-testbed.net/knowledge-base/obtaining-and-using-fabric-api-tokens/", - "learnArticleStarterQuestions": "https://learn.fabric-testbed.net/knowledge-base/fabric-user-roles-and-project-permissions/#managing-projects-in-the-real-world" + "learnArticleStarterQuestions": "https://learn.fabric-testbed.net/knowledge-base/fabric-user-roles-and-project-permissions/#managing-projects-in-the-real-world", + "learnArticleLonglivedTokens": "https://learn.fabric-testbed.net/knowledge-base/obtaining-and-using-fabric-api-tokens/#creating-long-lived-api-tokens" } \ No newline at end of file diff --git a/cm-app/src/services/httpService.js b/cm-app/src/services/httpService.js index ee5843c..37d90aa 100644 --- a/cm-app/src/services/httpService.js +++ b/cm-app/src/services/httpService.js @@ -4,31 +4,54 @@ import { toast } from "react-toastify"; axios.defaults.withCredentials = true; axios.interceptors.response.use(null, (error) => { - if (error.response && error.response.status === 401) { - // no auth cookie or cookie is expired. - window.location.href = "/logout"; + if (error.response && error.response.status === 401) { + // 1. the user has not logged in (errors.details: "Login required: ...") + // 2. the user login but haven't enrolled yet (errors.details: "Enrollment required: ...") + // 3. or the auth cookie is expired + const isCookieExpired = localStorage.getItem("cmUserStatus", "active"); + + const errors = error.response.data.errors; - // do not toast error message. - return Promise.reject(error); + if (errors && errors[0].details.includes("Login required")) { + localStorage.setItem("cmUserStatus", "unauthorized"); + localStorage.removeItem("userID"); } - // Timeout error. - if(error.code === 'ECONNABORTED') { - toast.error("Request timeout. Please try again."); - return Promise.reject(error); + if (errors && errors[0].details.includes("Enrollment required")) { + localStorage.setItem("cmUserStatus", "inactive"); + } + + // if cookie expired, log the user out; + // otherwise the user is not logged in and no need to auto logout. + if (isCookieExpired) { + // removed local storage items. + localStorage.removeItem("countdownTimerIntervalId"); + localStorage.removeItem("cmUserID"); + localStorage.removeItem("cmUserStatus"); + localStorage.removeItem("sessionTimeoutInterval1"); + localStorage.removeItem("sessionTimeoutInterval2"); + // log the user out. + window.location.href = "/logout"; } - - if (error.response && error.response.data - && error.response.data.errors && error.response.data.errors.length > 0) { - for (const err of error.response.data.errors) { - // console log and toast the human-readable error details. - console.log(`ERROR: ${err.details}`); - toast.error(err.details); - } - } + // do not toast error message. + return Promise.reject(error); + } + + // Timeout error. + if(error.code === 'ECONNABORTED') { + toast.error("Request timeout. Please try again."); return Promise.reject(error); } + + if (error.response && error.response.data && error.response.data.detail) { + // console log and toast the human-readable error details. + console.log(`ERROR: ${error.response.data.detail}`); + toast.error(error.response.data.detail); + } + + return Promise.reject(error); +} ); const httpServices = { @@ -39,4 +62,4 @@ const httpServices = { patch: axios.patch } -export default httpServices; \ No newline at end of file +export default httpServices; diff --git a/cm-app/src/styles/App.scss b/cm-app/src/styles/App.scss index c064968..32fa6ce 100644 --- a/cm-app/src/styles/App.scss +++ b/cm-app/src/styles/App.scss @@ -74,4 +74,9 @@ a:hover { .app-footer div { width: 100%; +} + +.table-container { + max-height: 300px; /* Adjust the max height as needed */ + overflow-y: auto; } \ No newline at end of file diff --git a/cm-app/src/utils/clearLocalStorage.js b/cm-app/src/utils/clearLocalStorage.js new file mode 100644 index 0000000..b65a8ff --- /dev/null +++ b/cm-app/src/utils/clearLocalStorage.js @@ -0,0 +1,11 @@ +export default function clearLocalStorage() { + // clear Local Storage when user logs out. + // remove old user status stored in browser. + localStorage.removeItem("idToken"); + localStorage.removeItem("refreshToken"); + localStorage.removeItem("cmUserID"); + localStorage.removeItem("cmUserStatus"); + localStorage.removeItem("countdownTimerIntervalId"); + localStorage.removeItem("sessionTimeoutIntervalId1"); + localStorage.removeItem("sessionTimeoutIntervalId2"); +} \ No newline at end of file diff --git a/cm-app/src/utils/sleep.js b/cm-app/src/utils/sleep.js new file mode 100644 index 0000000..0425c5f --- /dev/null +++ b/cm-app/src/utils/sleep.js @@ -0,0 +1,4 @@ +export default function sleep(time) { + // in milleseconds + return new Promise((resolve) => setTimeout(resolve, time)); +} diff --git a/cm-app/src/utils/toLocaleTime.js b/cm-app/src/utils/toLocaleTime.js new file mode 100644 index 0000000..61d1dd7 --- /dev/null +++ b/cm-app/src/utils/toLocaleTime.js @@ -0,0 +1,6 @@ +export default function toLocaleTime(UTCtime) { + const date = new Date(UTCtime.replace(' ','T')); + // The toLocaleString() returns a Date object as a string, using locale settings. + // The default language depends on the locale setup on your computer. + return date.toLocaleString(); +} diff --git a/config_template b/config_template index 90dbbc7..ab8be03 100644 --- a/config_template +++ b/config_template @@ -38,7 +38,10 @@ enable-vouch-cookie = True token-lifetime = 3600 project-names-ignore-list = Jupyterhub, fabric-active-users roles-list = facility-operators, project-leads +facility-operators-role = facility-operators allowed-scopes = cf, mf, all +max-llt-count-per-project = 5 +llt-role-suffix = tk [logging] logger = credmgr @@ -48,6 +51,7 @@ log-directory = /var/log/credmgr # ## The filename to be used for credmgr's log file. log-file = credmgr.log +metrics-log-file = metrics.log # ## The default log level for credmgr. log-level = DEBUG @@ -94,3 +98,9 @@ custom_claims = OPENID, EMAIL, PROFILE lifetime = 3600 cookie-name = fabric-service cookie-domain-name = cookie_domain + +[database] +db-user = fabric +db-password = fabric +db-name = credmgr +db-host = credmgr-db:5432 diff --git a/docker-compose.yml b/docker-compose.yml index b79cbaf..425bdb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,30 @@ ### For development purposes only ### version: "3.6" services: + database: + image: fabrictestbed/postgres:12.3 + container_name: credmgr-db + networks: + - frontend + - backend + restart: always + volumes: + - ./pg_data/data:${PGDATA:-/var/lib/postgresql/data} + - ./pg_data/logs:${POSTGRES_INITDB_WALDIR:-/var/log/postgresql} + environment: + - POSTGRES_HOST=${POSTGRES_HOST:-database} + - POSTGRES_PORT=5432 + - POSTGRES_MULTIPLE_DATABASES=${POSTGRES_DB:-credmgr} + - POSTGRES_USER=${POSTGRES_USER:-fabric} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-fabric} + - PGDATA=${PGDATA:-/var/lib/postgresql/data} + nginx: image: library/nginx:1 container_name: cm-nginx + networks: + - frontend + - backend ports: - 8443:443 volumes: @@ -19,8 +40,12 @@ services: dockerfile: Dockerfile image: fabrictestbed/credmgr:latest container_name: credmgr + networks: + - frontend + - backend depends_on: - nginx + - database volumes: - ./log/credmgr:/var/log/credmgr - ./config:/etc/credmgr/config @@ -38,6 +63,9 @@ services: dockerfile: Dockerfile image: fabrictestbed/cm-app:latest container_name: cm-app + networks: + - frontend + - backend #ports: # - 9000:3000 # volume mount docker based files so that they don't rebuild on each deployment @@ -49,6 +77,9 @@ services: vouch-proxy: container_name: cm-vouch-proxy + networks: + - frontend + - backend image: fabrictestbed/vouch-proxy:0.27.1 #ports: # - 9090:9090 @@ -56,3 +87,8 @@ services: - ./vouch:/config - ./data:/data restart: always + +networks: + frontend: + backend: + internal: true diff --git a/env.template b/env.template new file mode 100644 index 0000000..1099e28 --- /dev/null +++ b/env.template @@ -0,0 +1,7 @@ +# postgres configuration +POSTGRES_HOST=database +POSTGRES_PORT=5432 +POSTGRES_USER=fabric +POSTGRES_PASSWORD=fabric +PGDATA=/var/lib/postgresql/data/pgdata +POSTGRES_DB=credmgr \ No newline at end of file diff --git a/fabric_cm/__init__.py b/fabric_cm/__init__.py index 9d72f7b..55eadc2 100644 --- a/fabric_cm/__init__.py +++ b/fabric_cm/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.5.0" +__version__ = "1.6.2" __API_REFERENCE__ = "https://github.com/fabric-testbed/CredentialManager" diff --git a/fabric_cm/credmgr/common/utils.py b/fabric_cm/credmgr/common/utils.py new file mode 100644 index 0000000..3a0b00c --- /dev/null +++ b/fabric_cm/credmgr/common/utils.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2020 FABRIC Testbed +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Author Komal Thareja (kthare10@renci.org) +from fabric_cm.credmgr.external_apis.core_api import CoreApi + +from fabric_cm.credmgr.logging import LOG +from fss_utils.jwt_manager import ValidateCode +from fss_utils.vouch_encoder import VouchEncoder, CustomClaimsType, PTokens + +from fabric_cm.credmgr.config import CONFIG_OBJ + + +class Utils: + @staticmethod + def get_vouch_cookie(*, cookie: str, id_token: str, claims: dict) -> str: + """ + Build vouch cookie provided identity token and claims + @param cookie cookie + @param id_token identity token + @param claims claims + @return Vouch cookie + """ + vouch_cookie_enabled = CONFIG_OBJ.is_vouch_cookie_enabled() + if not vouch_cookie_enabled or cookie is not None: + return cookie + + vouch_secret = CONFIG_OBJ.get_vouch_secret() + vouch_compression = CONFIG_OBJ.is_vouch_cookie_compressed() + vouch_claims = CONFIG_OBJ.get_vouch_custom_claims() + vouch_cookie_lifetime = CONFIG_OBJ.get_vouch_cookie_lifetime() + vouch_helper = VouchEncoder(secret=vouch_secret, compression=vouch_compression) + + custom_claims = [] + for c in vouch_claims: + c_type = c.strip().upper() + + if c_type == CustomClaimsType.OPENID.name: + custom_claims.append(CustomClaimsType.OPENID) + + if c_type == CustomClaimsType.EMAIL.name: + custom_claims.append(CustomClaimsType.EMAIL) + + if c_type == CustomClaimsType.PROFILE.name: + custom_claims.append(CustomClaimsType.PROFILE) + + if c_type == CustomClaimsType.CILOGON_USER_INFO.name: + custom_claims.append(CustomClaimsType.CILOGON_USER_INFO) + + p_tokens = PTokens(id_token=id_token, idp_claims=claims) + + code, cookie_or_exception = vouch_helper.encode(custom_claims_type=custom_claims, p_tokens=p_tokens, + validity_in_seconds=vouch_cookie_lifetime) + + if code != ValidateCode.VALID: + LOG.error(f"Failed to encode the Vouch Cookie: {cookie_or_exception}") + raise cookie_or_exception + + return cookie_or_exception + + @staticmethod + def is_facility_operator(*, cookie: str): + """ + Validate if user with provided vouch cookie a facility operator + @param cookie cookie + @return True if user is FP; False otherwise + """ + core_api = CoreApi(api_server=CONFIG_OBJ.get_core_api_url(), cookie=cookie, + cookie_name=CONFIG_OBJ.get_vouch_cookie_name(), + cookie_domain=CONFIG_OBJ.get_vouch_cookie_domain_name()) + uuid, email = core_api.get_user_id_and_email() + roles = core_api.get_user_roles(uuid=uuid) + + if CONFIG_OBJ.get_facility_operator_role() in roles: + return True + return False + + @staticmethod + def is_short_lived(*, lifetime_in_hours: int): + if lifetime_in_hours * 3600 <= CONFIG_OBJ.get_token_life_time(): + return True + return False + + @staticmethod + def get_user_email(*, cookie: str): + core_api = CoreApi(api_server=CONFIG_OBJ.get_core_api_url(), cookie=cookie, + cookie_name=CONFIG_OBJ.get_vouch_cookie_name(), + cookie_domain=CONFIG_OBJ.get_vouch_cookie_domain_name()) + uuid, email = core_api.get_user_id_and_email() + return email + + @staticmethod + def get_project_id(*, project_name: str, cookie: str): + """ + Get the project Id for the given project name via Core API + @param project_name project name + @param cookie cookie + @return True if user is FP; False otherwise + """ + core_api = CoreApi(api_server=CONFIG_OBJ.get_core_api_url(), cookie=cookie, + cookie_name=CONFIG_OBJ.get_vouch_cookie_name(), + cookie_domain=CONFIG_OBJ.get_vouch_cookie_domain_name()) + + projects = core_api.get_user_projects(project_name=project_name) + + if len(projects) == 0: + raise Exception(f"Project '{project_name}' not found!") + + if len(projects) > 1: + raise Exception(f"More than one project found with name '{project_name}'!") + + if projects[0].get("uuid") is None: + raise Exception(f"Project Id for project '{project_name}' could not be found!") + + return projects[0].get("uuid") diff --git a/fabric_cm/credmgr/config/config.py b/fabric_cm/credmgr/config/config.py index 23c602e..6552de2 100644 --- a/fabric_cm/credmgr/config/config.py +++ b/fabric_cm/credmgr/config/config.py @@ -38,6 +38,7 @@ class Config: SECTION_JWT = 'jwt' SECTION_CORE_API = 'core-api' SECTION_VOUCH = 'vouch' + SECTION_DATABASE = 'database' # Runtime parameters REST_PORT = 'rest-port' @@ -48,11 +49,15 @@ class Config: PROJECT_NAMES_IGNORE_LIST = 'project-names-ignore-list' ROLES_LIST = 'roles-list' ALLOWED_SCOPES = 'allowed-scopes' + MAX_LLT_CNT_PER_PROJECT = 'max-llt-count-per-project' + FACILITY_OPERATOR_ROLE = 'facility-operators-role' + LLT_ROLE_SUFFIX = 'llt-role-suffix' # Logging Parameters LOGGER = 'logger' LOG_DIR = 'log-directory' LOG_FILE = 'log-file' + METRICS_LOG_FILE = 'metrics-log-file' LOG_RETAIN = 'log-retain' LOG_SIZE = 'log-size' LOG_LEVEL = 'log-level' @@ -78,6 +83,12 @@ class Config: JWT_PRIVATE_KEY = 'jwt-private-key' JWT_PRIVATE_KEY_PASS_PHRASE = 'jwt-pass-phrase' + # Database Parameters + DB_USER = "db-user" + DB_PASSWORD = "db-password" + DB_NAME = "db-name" + DB_HOST = "db-host" + # Project Registry Parameters CORE_API_URL = 'core-api-url' SSL_VERIFY = 'ssl_verify' @@ -139,6 +150,9 @@ def get_logger_dir(self) -> str: def get_logger_file(self) -> str: return self._get_config_from_section(self.SECTION_LOGGING, self.LOG_FILE) + def get_metrics_log_file(self) -> str: + return self._get_config_from_section(self.SECTION_LOGGING, self.METRICS_LOG_FILE) + def get_logger_level(self) -> str: return self._get_config_from_section(self.SECTION_LOGGING, self.LOG_LEVEL) @@ -238,3 +252,24 @@ def get_providers(self) -> dict: providers[provider]['revoke_uri'] = self.get_oauth_revoke_url() return providers + + def get_database_name(self) -> str: + return self._get_config_from_section(section_name=self.SECTION_DATABASE, parameter_name=self.DB_NAME) + + def get_database_user(self) -> str: + return self._get_config_from_section(section_name=self.SECTION_DATABASE, parameter_name=self.DB_USER) + + def get_database_password(self) -> str: + return self._get_config_from_section(section_name=self.SECTION_DATABASE, parameter_name=self.DB_PASSWORD) + + def get_database_host(self) -> str: + return self._get_config_from_section(section_name=self.SECTION_DATABASE, parameter_name=self.DB_HOST) + + def get_max_llt_per_project(self) -> int: + return int(self._get_config_from_section(self.SECTION_RUNTIME, self.MAX_LLT_CNT_PER_PROJECT)) + + def get_facility_operator_role(self) -> str: + return self._get_config_from_section(self.SECTION_RUNTIME, self.FACILITY_OPERATOR_ROLE) + + def get_llt_role_suffix(self) -> str: + return self._get_config_from_section(self.SECTION_RUNTIME, self.LLT_ROLE_SUFFIX) diff --git a/fabric_cm/credmgr/core/__init__.py b/fabric_cm/credmgr/core/__init__.py new file mode 100644 index 0000000..9036bd8 --- /dev/null +++ b/fabric_cm/credmgr/core/__init__.py @@ -0,0 +1,10 @@ +from fabric_cm.credmgr.logging import LOG + +from fabric_cm.credmgr.config import CONFIG_OBJ + +from fabric_cm.db.db_api import DbApi + +DB_OBJ = DbApi(database=CONFIG_OBJ.get_database_name(), user=CONFIG_OBJ.get_database_user(), + password=CONFIG_OBJ.get_database_password(), db_host=CONFIG_OBJ.get_database_host(), + logger=LOG) +DB_OBJ.create_db() \ No newline at end of file diff --git a/fabric_cm/credmgr/core/oauth_credmgr.py b/fabric_cm/credmgr/core/oauth_credmgr.py new file mode 100644 index 0000000..e3ae79d --- /dev/null +++ b/fabric_cm/credmgr/core/oauth_credmgr.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2020 FABRIC Testbed +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Author Komal Thareja (kthare10@renci.org) +""" +Module responsible for handling Credmgr REST API logic +""" + +import base64 +import enum +import hashlib +from datetime import datetime, timezone, timedelta +from enum import Enum +from typing import List, Dict, Any, Tuple + +import jwt +import requests +from jwt import ExpiredSignatureError +from requests_oauthlib import OAuth2Session + +from . import DB_OBJ +from fabric_cm.credmgr.config import CONFIG_OBJ +from fabric_cm.credmgr.logging import LOG, log_event +from fabric_cm.credmgr.token.token_encoder import TokenEncoder +from fabric_cm.credmgr.swagger_server import jwt_validator, jwk_public_key_rsa +from fss_utils.jwt_manager import ValidateCode + +from http.client import INTERNAL_SERVER_ERROR, NOT_FOUND + +from ..common.utils import Utils + + +class OAuthCredMgrError(Exception): + """ + CredMgr Exception + """ + def __init__(self, message: str, http_error_code: int = INTERNAL_SERVER_ERROR): + super().__init__(message) + self.http_error_code = http_error_code + + def get_http_error_code(self) -> int: + return self.http_error_code + + +class TokenState(Enum): + Nascent = enum.auto() + Valid = enum.auto() + Refreshed = enum.auto() + Revoked = enum.auto() + Expired = enum.auto() + + def __str__(self): + return self.name + + @classmethod + def list_values(cls) -> List[int]: + return list(map(lambda c: c.value, cls)) + + @classmethod + def list_names(cls) -> List[str]: + return list(map(lambda c: c.name, cls)) + + @staticmethod + def translate_list(states: List[str]) -> List[int] or None: + if states is None or len(states) == 0: + return states + + incoming_states = list(map(lambda x: x.lower(), states)) + + result = TokenState.list_values() + + for s in TokenState: + if s.name.lower() not in incoming_states: + result.remove(s.value) + + return result + + +class OAuthCredMgr: + """ + Credential Manager class responsible for handling various operations supported by REST APIs + It also provides support for scanning and cleaning up of expired tokens and key files. + """ + ID_TOKEN = "id_token" + REFRESH_TOKEN = "refresh_token" + CREATED_AT = "created_at" + EXPIRES_AT = "expires_at" + TOKEN_HASH = "token_hash" + COMMENT = "comment" + STATE = "state" + CREATED_FROM = "created_from" + ERROR = "error" + CLIENT_ID = "client_id" + CLIENT_SECRET = "client_secret" + REVOKE_URI = "revoke_uri" + TOKEN_URI = "token_uri" + UTF_8 = "utf-8" + TIME_FORMAT = "%Y-%m-%d %H:%M:%S %z" + COOKIE = "cookie" + UUID = "uuid" + EMAIL = "email" + PROJECTS = "projects" + + def __init__(self): + self.log = LOG + + @staticmethod + def __generate_sha256(*, token: str): + """ + Generate SHA 256 for a token + @param token token string + """ + # Create a new SHA256 hash object + sha256_hash = hashlib.sha256() + + # Convert the string to bytes and update the hash object + sha256_hash.update(token.encode('utf-8')) + + # Get the hexadecimal representation of the hash + sha256_hex = sha256_hash.hexdigest() + + return sha256_hex + + def __generate_token_and_save_info(self, ci_logon_id_token: str, scope: str, remote_addr: str, + comment: str = None, cookie: str = None, lifetime: int = 4, + refresh: bool = False, project_id: str = None, + project_name: str = None) -> Dict[str, str]: + """ + Generate Fabric Token and save the corresponding meta information in the database + @param ci_logon_id_token CI logon Identity Token + @param project_id Project Id + @param project_name Project Name + @param scope Token scope; allowed values (cf, mf, all) + @param remote_addr Remote Address + @param comment Comment + @param cookie Vouch Cookie + @param lifetime Token lifetime in hours; default 1 hour; max is 9 weeks i.e. 1512 hours + @param refresh Flag indicating if token was refreshed (True) or created new (False) + """ + if project_name is None and project_id is None: + raise OAuthCredMgrError(f"CredMgr: Either Project ID: '{project_id}' or Project Name'{project_name}' " + f"must be specified") + + self.log.debug("CILogon Token: %s", ci_logon_id_token) + + # validate the token + if jwt_validator is not None: + LOG.info("Validating CI Logon token") + code, claims_or_exception = jwt_validator.validate_jwt(token=ci_logon_id_token) + if code is not ValidateCode.VALID: + LOG.error(f"Unable to validate provided token: {code}/{claims_or_exception}") + raise claims_or_exception + + # Create an encoder + token_encoder = TokenEncoder(id_token=ci_logon_id_token, idp_claims=claims_or_exception, + project_id=project_id, project_name=project_name, + scope=scope, cookie=cookie) + + # convert lifetime to seconds + validity = lifetime * 3600 + private_key = CONFIG_OBJ.get_jwt_private_key() + pass_phrase = CONFIG_OBJ.get_jwt_private_key_pass_phrase() + kid = CONFIG_OBJ.get_jwt_public_key_kid() + + # token timestamps + created_at = datetime.now(timezone.utc) + expires_at = created_at + timedelta(hours=lifetime) + + # create/encode the token + token = token_encoder.encode(private_key=private_key, validity_in_seconds=validity, kid=kid, + pass_phrase=pass_phrase) + + # Generate SHA256 hash + token_hash = self.__generate_sha256(token=token) + + state = TokenState.Valid + action = "create" + if refresh: + state = TokenState.Refreshed + comment = "Refreshed via API" + action = "refresh" + + if comment is None: + comment = "Created via GUI" + + # Delete any expired tokens + self.delete_expired_tokens(user_email=token_encoder.claims.get(self.EMAIL), + user_id=token_encoder.claims.get(self.UUID)) + + # Add token meta info to the database + DB_OBJ.add_token(user_id=token_encoder.claims.get(self.UUID), + user_email=token_encoder.claims.get(self.EMAIL), + project_id=token_encoder.project_id, token_hash=token_hash, created_at=created_at, + expires_at=expires_at, state=state.value, created_from=remote_addr, + comment=comment) + + log_event(token_hash=token_hash, action=action, project_id=token_encoder.project_id, + user_id=token_encoder.claims.get(self.UUID), user_email=token_encoder.claims.get(self.EMAIL)) + + return {self.TOKEN_HASH: token_hash, + self.CREATED_AT: created_at.strftime(OAuthCredMgr.TIME_FORMAT), + self.EXPIRES_AT: expires_at.strftime(OAuthCredMgr.TIME_FORMAT), + self.STATE: str(state), + self.COMMENT: comment, + self.CREATED_FROM: remote_addr, + self.ID_TOKEN: token} + else: + LOG.warning("JWT Token validator not initialized, skipping validation") + + def create_token(self, project_id: str, project_name: str, scope: str, ci_logon_id_token: str, refresh_token: str, + remote_addr: str, user_email: str, comment: str = None, cookie: str = None, + lifetime: int = 4) -> dict: + """ + Generates key file and return authorization url for user to + authenticate itself and also returns user id + + @param project_id: Project Id of the project for which token is requested, by default it is set to 'all' + @param project_name: Project Name + @param scope: Scope of the requested token, by default it is set to 'all' + @param ci_logon_id_token: CI logon Identity Token + @param refresh_token: Refresh Token + @param remote_addr: Remote Address + @param user_email: User's email + @param comment: Comment + @param cookie: Vouch Proxy Cookie + @param lifetime: Token lifetime in hours default(1 hour) + + @returns dict containing id_token and refresh_token + @raises Exception in case of error + """ + + self.validate_scope(scope=scope) + + if project_name is None and project_id is None: + raise OAuthCredMgrError(f"CredMgr: Either Project ID: '{project_id}' or Project Name'{project_name}' " + f"must be specified") + + if scope is None: + raise OAuthCredMgrError("CredMgr: Missing required parameter 'scope'!") + + if project_id is None: + project_id = Utils.get_project_id(project_name=project_name, cookie=cookie) + + short = Utils.is_short_lived(lifetime_in_hours=lifetime) + LOG.info(f"Token lifetime: {lifetime} short: {short}") + + if not short: + long_lived_tokens = self.get_tokens(project_id=project_id, user_email=user_email) + if long_lived_tokens is not None and len(long_lived_tokens) > CONFIG_OBJ.get_max_llt_per_project(): + raise OAuthCredMgrError(f"User: {user_email} already has {CONFIG_OBJ.get_max_llt_per_project()} " + f"long lived tokens") + + # Generate the Token + result = self.__generate_token_and_save_info(ci_logon_id_token=ci_logon_id_token, project_id=project_id, + scope=scope, remote_addr=remote_addr, cookie=cookie, + lifetime=lifetime, comment=comment, project_name=project_name) + + # Only include refresh token for short lived tokens + if short: + result[self.REFRESH_TOKEN] = refresh_token + return result + + def refresh_token(self, refresh_token: str, project_id: str, project_name: str, scope: str, + remote_addr: str, cookie: str = None) -> dict: + """ + Refreshes a token from CILogon and generates Fabric token using project and scope saved in Database + + @param project_id: Project Id of the project for which token is requested, by default it is set to 'all' + @param project_name: Project Name + @param scope: Scope of the requested token, by default it is set to 'all' + @param refresh_token: Refresh Token + @param remote_addr: Remote IP + @param cookie: Vouch Proxy Cookie + @returns dict containing id_token and refresh_token + + @raises Exception in case of error + """ + if project_name is None and project_id is None: + raise OAuthCredMgrError(f"CredMgr: Either Project ID: '{project_id}' or Project Name'{project_name}' " + f"must be specified") + + self.validate_scope(scope=scope) + + if OAuth2Session is None or refresh_token is None: + raise ImportError("No module named OAuth2Session or refresh_token not provided") + + provider = CONFIG_OBJ.get_oauth_provider() + providers = CONFIG_OBJ.get_providers() + + refresh_token_dict = {self.REFRESH_TOKEN: refresh_token} + self.log.debug(f"Incoming refresh_token: {refresh_token}") + + # refresh the token (provides both new refresh and access tokens) + oauth_client = OAuth2Session(providers[provider][self.CLIENT_ID], token=refresh_token_dict) + new_token = oauth_client.refresh_token(providers[provider][self.TOKEN_URI], + client_id=providers[provider][self.CLIENT_ID], + client_secret=providers[provider][self.CLIENT_SECRET]) + + try: + new_refresh_token = new_token.pop(self.REFRESH_TOKEN) + id_token = new_token.pop(self.ID_TOKEN) + except KeyError: + self.log.error("No refresh or id token returned") + raise OAuthCredMgrError("No refresh or id token returned") + self.log.debug(f"new_refresh_token: {new_refresh_token}") + + try: + result = self.__generate_token_and_save_info(ci_logon_id_token=id_token, project_id=project_id, + project_name=project_name, scope=scope, + cookie=cookie, refresh=True, remote_addr=remote_addr) + result[self.REFRESH_TOKEN] = new_refresh_token + return result + except Exception as e: + self.log.error(f"Exception error while generating Fabric Token: {e}") + self.log.error(f"Failed generating the token but still returning refresh token") + exception_string = str(e) + if exception_string.__contains__("could not be associated with a pending flow"): + exception_string = "Specified refresh token is expired and can not be found in the database." + error_string = f"error: {exception_string}, {self.REFRESH_TOKEN}: {new_refresh_token}" + raise OAuthCredMgrError(error_string) + + def revoke_token(self, refresh_token: str): + """ + Revoke a refresh token + + @returns dictionary containing status of the operation + @raises Exception in case of error + """ + if OAuth2Session is None or refresh_token is None: + raise ImportError("No module named OAuth2Session or revoke_token not provided") + + provider = CONFIG_OBJ.get_oauth_provider() + providers = CONFIG_OBJ.get_providers() + + auth = providers[provider][self.CLIENT_ID] + ":" + providers[provider][self.CLIENT_SECRET] + encoded_auth = base64.b64encode(bytes(auth, self.UTF_8)) + + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Basic ' + str(encoded_auth, self.UTF_8) + } + + data = f"token={refresh_token}&token_type_hint=refresh_token" + + response = requests.post(providers[provider][self.REVOKE_URI], headers=headers, data=data) + self.log.debug("Response Status=%d", response.status_code) + self.log.debug("Response Reason=%s", response.reason) + self.log.debug("Response content=%s", response.content) + self.log.debug(str(response.content, self.UTF_8)) + if response.status_code != 200: + raise OAuthCredMgrError("Refresh token could not be revoked!") + + def revoke_identity_token(self, token_hash: str, cookie: str, user_email: str = None, project_id: str = None): + """ + Revoke a fabric identity token + + :param token_hash: Token's hash + :type token_hash: str + :param user_email: User's email + :type user_email: str + :param cookie: Cookie + :type cookie: str + + @returns dictionary containing status of the operation + @raises Exception in case of error + """ + if user_email is None and token_hash is None: + raise OAuthCredMgrError(f"User Id/Email or Token Hash required") + + # Facility Operator query all tokens + if Utils.is_facility_operator(cookie=cookie): + tokens = self.get_tokens(token_hash=token_hash) + # Otherwise query only this user's tokens + else: + tokens = self.get_tokens(token_hash=token_hash, user_email=user_email, project_id=project_id) + + if tokens is None or len(tokens) == 0: + raise OAuthCredMgrError(http_error_code=NOT_FOUND, + message=f"Token# {token_hash} not found!") + + if tokens[0].get(self.STATE) == str(TokenState.Revoked): + LOG.info(f"Token {token_hash} for user {tokens[0].get('user_email')}/{tokens[0].get('user_id')} " + f"is already revoked!") + return + DB_OBJ.update_token(token_hash=token_hash, state=TokenState.Revoked.value) + + log_event(token_hash=token_hash, action="revoke", project_id=tokens[0].get('project_id'), + user_id=tokens[0].get('user_id'), user_email=tokens[0].get('user_email')) + + def get_token_revoke_list(self, project_id: str, user_email: str = None, user_id: str = None) -> List[str]: + """Get token revoke list i.e. list of revoked identity token hashes + + Get token revoke list i.e. list of revoked identity token hashes for a user in a project # noqa: E501 + + :param project_id: Project identified by universally unique identifier + :type project_id: str + :param user_email: User's email + :type user_email: str + :param user_id: User identified by universally unique identifier + :type user_id: str + + @return list of sting + """ + result = [] + + tokens = self.get_tokens(project_id=project_id, user_email=user_email, user_id=user_id, + states=[str(TokenState.Revoked)], query_all=True) + if tokens is None: + return result + for t in tokens: + result.append(t.get(self.TOKEN_HASH)) + return result + + def get_tokens(self, *, user_id: str = None, user_email: str = None, project_id: str = None, token_hash: str = None, + expires: datetime = None, states: List[str] = None, offset: int = 0, + limit: int = 5, query_all: bool = False) -> List[Dict[str, Any]]: + """ + Get Tokens + @return list of tokens + """ + if not query_all and project_id is None and user_id is None and user_email is None and token_hash is None: + raise OAuthCredMgrError(f"User Id/Email/Token Hash or Project Id required") + + self.delete_expired_tokens(user_email=user_email, user_id=user_id) + tokens = DB_OBJ.get_tokens(user_id=user_id, user_email=user_email, project_id=project_id, + token_hash=token_hash, expires=expires, + states=TokenState.translate_list(states=states), + offset=offset, limit=limit) + now = datetime.now(timezone.utc) + # Change the state from integer value to string + for t in tokens: + state = TokenState(t[self.STATE]) + if t.get(self.EXPIRES_AT) < now: + state = TokenState.Expired + t[self.STATE] = str(state) + + return tokens + + @staticmethod + def validate_scope(scope: str): + allowed_scopes = CONFIG_OBJ.get_allowed_scopes() + if scope not in allowed_scopes: + raise OAuthCredMgrError(f"Scope {scope} is not allowed! Allowed scope values: {allowed_scopes}") + + def delete_tokens(self, user_email: str = None, user_id: str = None, token_hash: str = None): + """ + Delete Expired Tokens + @param user_id user uuid + @param user_email user email + @param token_hash token hash + """ + tokens = DB_OBJ.get_tokens(user_email=user_email, user_id=user_id, token_hash=token_hash) + if tokens is None: + return + + # Remove the expired tokens + for t in tokens: + DB_OBJ.remove_token(token_hash=t.get(self.TOKEN_HASH)) + log_event(token_hash=t.get(self.TOKEN_HASH), action="delete", project_id=tokens[0].get('project_id'), + user_id=tokens[0].get('user_id'), user_email=tokens[0].get('user_email')) + + def delete_expired_tokens(self, user_email: str = None, user_id: str = None): + """ + Delete Expired Tokens + @param user_id user uuid + @param user_email user email + """ + tokens = DB_OBJ.get_tokens(user_email=user_email, user_id=user_id, expires=datetime.now(timezone.utc)) + if tokens is None: + return + + # Remove the expired tokens + for t in tokens: + DB_OBJ.remove_token(token_hash=t.get(self.TOKEN_HASH)) + log_event(token_hash=t.get(self.TOKEN_HASH), action="delete", project_id=tokens[0].get('project_id'), + user_id=tokens[0].get('user_id'), user_email=tokens[0].get('user_email')) + + def validate_token(self, *, token: str) -> Tuple[str, dict]: + """ + Validate a token + @param token token + @return token state and claims + """ + claims = {} + # get kid from token + try: + kid = jwt.get_unverified_header(token).get('kid', None) + alg = jwt.get_unverified_header(token).get('alg', None) + except jwt.DecodeError as e: + raise Exception(ValidateCode.UNPARSABLE_TOKEN) + + if kid is None: + raise Exception(ValidateCode.UNSPECIFIED_KEY) + + if alg is None: + raise Exception(ValidateCode.UNSPECIFIED_ALG) + + if kid != jwk_public_key_rsa['kid']: + raise Exception(ValidateCode.UNKNOWN_KEY) + + key = jwt.algorithms.RSAAlgorithm.from_jwk(jwk_public_key_rsa) + + options = {"verify_exp": True, "verify_aud": True} + + # options https://pyjwt.readthedocs.io/en/latest/api.html + try: + claims = jwt.decode(token, key=key, algorithms=[alg], options=options, + audience=CONFIG_OBJ.get_oauth_client_id()) + + # Check if the Token is Revoked + token_hash = self.__generate_sha256(token=token) + token_found_in_db = self.get_tokens(token_hash=token_hash) + if token_found_in_db is None or len(token_found_in_db) == 0: + raise OAuthCredMgrError(http_error_code=NOT_FOUND, message="Token not found!") + + state = token_found_in_db[0].get(self.STATE) + + except ExpiredSignatureError: + state = TokenState.Expired + except Exception: + raise Exception(ValidateCode.INVALID) + + return str(state), claims diff --git a/fabric_cm/credmgr/credential_managers/__init__.py b/fabric_cm/credmgr/credential_managers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fabric_cm/credmgr/credential_managers/abstract_credential_manager.py b/fabric_cm/credmgr/credential_managers/abstract_credential_manager.py deleted file mode 100644 index 0c09fca..0000000 --- a/fabric_cm/credmgr/credential_managers/abstract_credential_manager.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# MIT License -# -# Copyright (c) 2020 FABRIC Testbed -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# Author Komal Thareja (kthare10@renci.org) -""" -Base class for Credential Manager -""" -from abc import ABCMeta, abstractmethod -import six - - -@six.add_metaclass(ABCMeta) -class AbstractCredentialManager: - """ - Abstract Credential Manager class - """ - @abstractmethod - def create_token(self, project: str, scope: str, ci_logon_id_token: str, - refresh_token: str, cookie: str = None) -> dict: - """ - Generates key file and return authorization url for user to authenticate itself and also returns user id - - @param project: Project for which token is requested, by default it is set to 'all' - @param scope: Scope of the requested token, by default it is set to 'all' - @param ci_logon_id_token: CI logon Identity Token - @param refresh_token: Refresh Token - @param cookie: Vouch Proxy Cookie - - @returns dict containing id_token and refresh_token - @raises Exception in case of error - """ - - @abstractmethod - def refresh_token(self, refresh_token: str, project: str, scope: str, cookie: str = None) -> dict: - """ - Refreshes a token from CILogon and generates Fabric token using project and scope saved in Database - - @param project: Project for which token is requested, by default it is set to 'all' - @param scope: Scope of the requested token, by default it is set to 'all' - @param refresh_token: Refresh Token - @param cookie: Vouch Proxy Cookie - @returns dict containing id_token and refresh_token - - @raises Exception in case of error - """ - - @abstractmethod - def revoke_token(self, refresh_token: str): - """ - Revoke a refresh token - - @returns dictionary containing status of the operation - @raises Exception in case of error - """ diff --git a/fabric_cm/credmgr/credential_managers/oauth_credmgr.py b/fabric_cm/credmgr/credential_managers/oauth_credmgr.py deleted file mode 100644 index a30cb96..0000000 --- a/fabric_cm/credmgr/credential_managers/oauth_credmgr.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 -# MIT License -# -# Copyright (c) 2020 FABRIC Testbed -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# Author Komal Thareja (kthare10@renci.org) -""" -Module responsible for handling Credmgr REST API logic -""" - -import base64 -from datetime import datetime - -import requests -from requests_oauthlib import OAuth2Session - -from .abstract_credential_manager import AbstractCredentialManager -from fabric_cm.credmgr.config import CONFIG_OBJ -from fabric_cm.credmgr.logging import LOG -from fabric_cm.credmgr.token.fabric_token_encoder import FabricTokenEncoder -from fabric_cm.credmgr.swagger_server import jwt_validator -from fss_utils.jwt_manager import ValidateCode - - -class OAuthCredmgr(AbstractCredentialManager): - """ - Credential Manager class responsible for handling various operations supported by REST APIs - It also provides support for scanning and cleaning up of expired tokens and key files. - """ - ID_TOKEN = "id_token" - REFRESH_TOKEN = "refresh_token" - CREATED_AT = "created_at" - TIME_FORMAT = "%Y-%m-%d %H:%M:%S" - ERROR = "error" - CLIENT_ID = "client_id" - CLIENT_SECRET = "client_secret" - REVOKE_URI = "revoke_uri" - TOKEN_URI = "token_uri" - UTF_8 = "utf-8" - - def __init__(self): - self.log = LOG - - def _generate_fabric_token(self, ci_logon_id_token: str, project: str, - scope: str, cookie: str = None): - self.log.debug("CILogon Token: %s", ci_logon_id_token) - # validate the token - if jwt_validator is not None: - LOG.info("Validating CI Logon token") - code, claims_or_exception = jwt_validator.validate_jwt(token=ci_logon_id_token) - if code is not ValidateCode.VALID: - LOG.error(f"Unable to validate provided token: {code}/{claims_or_exception}") - raise claims_or_exception - - fabric_token_encoder = FabricTokenEncoder(id_token=ci_logon_id_token, idp_claims=claims_or_exception, - project=project, scope=scope, cookie=cookie) - - validity = CONFIG_OBJ.get_token_life_time() - private_key = CONFIG_OBJ.get_jwt_private_key() - pass_phrase = CONFIG_OBJ.get_jwt_private_key_pass_phrase() - kid = CONFIG_OBJ.get_jwt_public_key_kid() - - return fabric_token_encoder.encode(private_key=private_key, validity_in_seconds=validity, kid=kid, - pass_phrase=pass_phrase) - else: - LOG.warning("JWT Token validator not initialized, skipping validation") - - return None - - def create_token(self, project: str, scope: str, ci_logon_id_token: str, - refresh_token: str, cookie: str = None) -> dict: - """ - Generates key file and return authorization url for user to - authenticate itself and also returns user id - - @param project: Project for which token is requested, by default it is set to 'all' - @param scope: Scope of the requested token, by default it is set to 'all' - @param ci_logon_id_token: CI logon Identity Token - @param refresh_token: Refresh Token - @param cookie: Vouch Proxy Cookie - - @returns dict containing id_token and refresh_token - @raises Exception in case of error - """ - - self.validate_scope(scope=scope) - - if project is None or scope is None: - raise OAuthCredMgrError("CredMgr: Cannot request to create a token, " - "Missing required parameter 'project' or 'scope'!") - - id_token = self._generate_fabric_token(ci_logon_id_token=ci_logon_id_token, - project=project, scope=scope, - cookie=cookie) - - result = {self.ID_TOKEN: id_token, self.REFRESH_TOKEN: refresh_token, - self.CREATED_AT: datetime.strftime(datetime.utcnow(), self.TIME_FORMAT)} - return result - - def refresh_token(self, refresh_token: str, project: str, scope: str, cookie: str = None) -> dict: - """ - Refreshes a token from CILogon and generates Fabric token using project and scope saved in Database - - @param project: Project for which token is requested, by default it is set to 'all' - @param scope: Scope of the requested token, by default it is set to 'all' - @param refresh_token: Refresh Token - @param cookie: Vouch Proxy Cookie - @returns dict containing id_token and refresh_token - - @raises Exception in case of error - """ - - self.validate_scope(scope=scope) - - if OAuth2Session is None or refresh_token is None: - raise ImportError("No module named OAuth2Session or refresh_token not provided") - - provider = CONFIG_OBJ.get_oauth_provider() - providers = CONFIG_OBJ.get_providers() - - refresh_token_dict = {self.REFRESH_TOKEN: refresh_token} - self.log.debug(f"Incoming refresh_token: {refresh_token}") - - # refresh the token (provides both new refresh and access tokens) - oauth_client = OAuth2Session(providers[provider][self.CLIENT_ID], token=refresh_token_dict) - new_token = oauth_client.refresh_token(providers[provider][self.TOKEN_URI], - client_id=providers[provider][self.CLIENT_ID], - client_secret=providers[provider][self.CLIENT_SECRET]) - - new_refresh_token = None - try: - new_refresh_token = new_token.pop(self.REFRESH_TOKEN) - id_token = new_token.pop(self.ID_TOKEN) - except KeyError: - self.log.error("No refresh or id token returned") - raise OAuthCredMgrError("No refresh or id token returned") - self.log.debug(f"new_refresh_token: {new_refresh_token}") - - try: - id_token = self._generate_fabric_token(ci_logon_id_token=id_token, - project=project, scope=scope, cookie=cookie) - result = {self.ID_TOKEN: id_token, self.REFRESH_TOKEN: new_refresh_token, - self.CREATED_AT: datetime.strftime(datetime.utcnow(), self.TIME_FORMAT)} - - return result - except Exception as e: - self.log.error(f"Exception error while generating Fabric Token: {e}") - self.log.error(f"Failed generating the token but still returning refresh token") - exception_string = str(e) - if exception_string.__contains__("could not be associated with a pending flow"): - exception_string = "Specified refresh token is expired and can not be found in the database." - error_string = f"error: {exception_string}, {self.REFRESH_TOKEN}: {new_refresh_token}" - raise OAuthCredMgrError(error_string) - - def revoke_token(self, refresh_token: str): - """ - Revoke a refresh token - - @returns dictionary containing status of the operation - @raises Exception in case of error - """ - if OAuth2Session is None or refresh_token is None: - raise ImportError("No module named OAuth2Session or revoke_token not provided") - - provider = CONFIG_OBJ.get_oauth_provider() - providers = CONFIG_OBJ.get_providers() - - auth = providers[provider][self.CLIENT_ID] + ":" + providers[provider][self.CLIENT_SECRET] - encoded_auth = base64.b64encode(bytes(auth, self.UTF_8)) - - headers = { - 'Accept': 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'Basic ' + str(encoded_auth, self.UTF_8) - } - - data = f"token={refresh_token}&token_type_hint=refresh_token" - - response = requests.post(providers[provider][self.REVOKE_URI], headers=headers, data=data) - self.log.debug("Response Status=%d", response.status_code) - self.log.debug("Response Reason=%s", response.reason) - self.log.debug("Response content=%s", response.content) - self.log.debug(str(response.content, self.UTF_8)) - if response.status_code != 200: - raise OAuthCredMgrError("Refresh token could not be revoked!") - - @staticmethod - def validate_scope(scope: str): - allowed_scopes = CONFIG_OBJ.get_allowed_scopes() - if scope not in allowed_scopes: - raise OAuthCredMgrError(f"Scope {scope} is not allowed! Allowed scope values: {allowed_scopes}") - - -class OAuthCredMgrError(Exception): - """ - Credmgr Exception - """ diff --git a/fabric_cm/credmgr/external_apis/core_api.py b/fabric_cm/credmgr/external_apis/core_api.py index 3a6e55b..2a7d13c 100644 --- a/fabric_cm/credmgr/external_apis/core_api.py +++ b/fabric_cm/credmgr/external_apis/core_api.py @@ -23,7 +23,7 @@ # # Author Komal Thareja (kthare10@renci.org) import datetime -from typing import Tuple +from typing import Tuple, List import requests @@ -41,39 +41,34 @@ def __init__(self, api_server: str, cookie: str, cookie_name: str, cookie_domain self.cookie_name = cookie_name self.cookie_domain = cookie_domain - def get_user_and_project_info(self, project_id: str) -> Tuple[str, str, list, list]: - """ - Determine User's info using CORE API - :param project_id: Project Id - :return the user uuid, roles, projects - - :returns a tuple containing user specific roles and project tags - """ if self.api_server is None or self.cookie is None: raise CoreApiError(f"Core URL: {self.api_server} or Cookie: {self.cookie} not available") # Create Session - s = requests.Session() + self.session = requests.Session() # Set the Cookie cookie_obj = requests.cookies.create_cookie( name=self.cookie_name, value=self.cookie ) - s.cookies.set_cookie(cookie_obj) - LOG.debug(f"Using vouch cookie: {s.cookies}") + self.session.cookies.set_cookie(cookie_obj) + LOG.debug(f"Using vouch cookie: {self.session.cookies}") # Set the headers headers = { 'Accept': 'application/json', 'Content-Type': "application/json" } - s.headers.update(headers) - ssl_verify = CONFIG_OBJ.is_core_api_ssl_verify() + self.session.headers.update(headers) - # WhoAmI + def get_user_id_and_email(self) -> Tuple[str, str]: + """ + Return User's uuid by querying via /whoami Core API + @return User's uuid + """ url = f'{self.api_server}/whoami' - response = s.get(url, verify=ssl_verify) + response = self.session.get(url, verify=CONFIG_OBJ.is_core_api_ssl_verify()) if response.status_code != 200: raise CoreApiError(f"Core API error occurred status_code: {response.status_code} " f"message: {response.content}") @@ -81,21 +76,98 @@ def get_user_and_project_info(self, project_id: str) -> Tuple[str, str, list, li LOG.debug(f"GET WHOAMI Response : {response.json()}") uuid = response.json().get("results")[0]["uuid"] email = response.json().get("results")[0]["email"] + return uuid, email - # Get Project - if project_id.lower() == "all": - # Get All projects - url = f"{self.api_server}/projects?offset=0&limit=50&person_uuid={uuid}&sort_by=name&order_by=asc" - else: - url = f"{self.api_server}/projects/{project_id}" - response = s.get(url, verify=ssl_verify) + def get_user_roles(self, uuid: str): + """ + Get User by UUID to get roles (Facility Operator is not Project Specific + @param uuid User's uuid + @return return user's roles + """ + # Get User by UUID to get roles (Facility Operator is not Project Specific, + # so need the roles from people end point) + url = f"{self.api_server}/people/{uuid}?as_self=true" + response = self.session.get(url, verify=CONFIG_OBJ.is_core_api_ssl_verify()) + + if response.status_code != 200: + raise CoreApiError(f"Core API error occurred status_code: {response.status_code} " + f"message: {response.content}") + + LOG.debug(f"GET PEOPLE Response : {response.json()}") + + roles = response.json().get("results")[0]["roles"] + return roles + + def __get_user_project_by_id(self, *, project_id: str): + url = f"{self.api_server}/projects/{project_id}" + response = self.session.get(url, verify=CONFIG_OBJ.is_core_api_ssl_verify()) if response.status_code != 200: raise CoreApiError(f"Core API error occurred status_code: {response.status_code} " f"message: {response.content}") LOG.debug(f"GET Project Response : {response.json()}") - projects_res = response.json().get("results") + + return response.json().get("results") + + def __get_user_projects(self, *, project_name: str = None): + offset = 0 + limit = 50 + uuid, email = self.get_user_id_and_email() + result = [] + total_fetched = 0 + + while True: + if project_name is not None: + url = f"{self.api_server}/projects?search={project_name}&offset={offset}&limit={limit}" \ + f"&person_uuid={uuid}&sort_by=name&order_by=asc" + else: + url = f"{self.api_server}/projects?offset={offset}&limit={limit}&person_uuid={uuid}" \ + f"&sort_by=name&order_by=asc" + + response = self.session.get(url, verify=CONFIG_OBJ.is_core_api_ssl_verify()) + + if response.status_code != 200: + raise CoreApiError(f"Core API error occurred status_code: {response.status_code} " + f"message: {response.content}") + + LOG.debug(f"GET Project Response : {response.json()}") + + size = response.json().get("size") + total = response.json().get("total") + projects = response.json().get("results") + + total_fetched += size + + for x in projects: + result.append(x) + + if total_fetched == total: + break + offset = size + limit += limit + + return result + + def get_user_projects(self, project_name: str = None, project_id: str = None) -> List[dict]: + if project_id is not None and project_id != "all": + return self.__get_user_project_by_id(project_id=project_id) + elif project_name is not None and project_name != "all": + return self.__get_user_projects(project_name=project_name) + else: + return self.__get_user_projects() + + def get_user_and_project_info(self, project_id: str) -> Tuple[str, str, list, list]: + """ + Determine User's info using CORE API + :param project_id: Project Id + :return the user uuid, roles, projects + + :returns a tuple containing user specific roles and project tags + """ + uuid, email = self.get_user_id_and_email() + + projects_res = self.get_user_projects(project_id=project_id) projects = [] for p in projects_res: @@ -131,18 +203,7 @@ def get_user_and_project_info(self, project_id: str) -> Tuple[str, str, list, li if len(projects) == 0: raise CoreApiError(f"User is not a member of Project: {project_id}") - # Get User by UUID to get roles (Facility Operator is not Project Specific, - # so need the roles from people end point) - url = f"{self.api_server}/people/{uuid}?as_self=true" - response = s.get(url, verify=ssl_verify) - - if response.status_code != 200: - raise CoreApiError(f"Core API error occurred status_code: {response.status_code} " - f"message: {response.content}") - - LOG.debug(f"GET PEOPLE Response : {response.json()}") - - roles = response.json().get("results")[0]["roles"] + roles = self.get_user_roles(uuid=uuid) return email, uuid, roles, projects @@ -150,4 +211,4 @@ class CoreApiError(Exception): """ Core Exception """ - pass + pass \ No newline at end of file diff --git a/fabric_cm/credmgr/external_apis/ldap.py b/fabric_cm/credmgr/external_apis/ldap.py index bfae2da..9433ef1 100644 --- a/fabric_cm/credmgr/external_apis/ldap.py +++ b/fabric_cm/credmgr/external_apis/ldap.py @@ -48,18 +48,18 @@ def __init__(self): self.server = Server(host=self.ldap_host, use_ssl=True, get_info=ALL) - def get_user_and_project_info(self, eppn: str, email: str, project_id: str, sub: str) -> (list, list): + def get_user_and_project_info(self, eppn: str, email: str, sub: str, project_id: str) -> (list, list): """ Return active projects for a user identified by eppn or email @params eppn: eppn + @param sub: sub @params email: user email @params project_id: project id - @param sub: sub @return tuple of roles and project tags(always empty) as tags are not in CoManage """ if eppn: ldap_search_filter = '(eduPersonPrincipalName=' + eppn + ')' - elif sub: + elif sub is not None: ldap_search_filter = '(uid=' + sub + ')' else: ldap_search_filter = '(mail=' + email + ')' @@ -80,7 +80,7 @@ def get_user_and_project_info(self, eppn: str, email: str, project_id: str, sub: attributes = conn.entries[0]['isMemberOf'] attributes = [attr for attr in attributes if 'active' in attr] if email is None: - email = conn.entries[0]['mail'] + email = str(conn.entries[0]['mail']) else: attributes = None conn.unbind() @@ -128,4 +128,4 @@ def get(self): self.__instance = CmLdapMgr() return self.__instance - get = classmethod(get) + get = classmethod(get) \ No newline at end of file diff --git a/fabric_cm/credmgr/logging/__init__.py b/fabric_cm/credmgr/logging/__init__.py index 59facd2..c83f825 100644 --- a/fabric_cm/credmgr/logging/__init__.py +++ b/fabric_cm/credmgr/logging/__init__.py @@ -22,5 +22,34 @@ # SOFTWARE. # # Author Komal Thareja (kthare10@renci.org) -from fabric_cm.credmgr.logging.log import get_logger -LOG, FILE_HANDLER = get_logger() \ No newline at end of file +from fabric_cm.credmgr.config import CONFIG_OBJ + +from fabric_cm.credmgr.logging.log_helper import LogHelper +LOG = LogHelper.make_logger(log_dir=CONFIG_OBJ.get_logger_dir(), + log_file=CONFIG_OBJ.get_logger_file(), + log_level=CONFIG_OBJ.get_logger_level(), + log_retain=CONFIG_OBJ.get_logger_retain(), + log_size=CONFIG_OBJ.get_logger_size(), + logger=CONFIG_OBJ.get_logger_name()) + +METRICS_LOG = LogHelper.make_logger(log_dir=CONFIG_OBJ.get_logger_dir(), + log_file=CONFIG_OBJ.get_metrics_log_file(), + log_level=CONFIG_OBJ.get_logger_level(), + log_retain=CONFIG_OBJ.get_logger_retain(), + log_size=CONFIG_OBJ.get_logger_size(), + logger=f"{CONFIG_OBJ.get_logger_name()}-metrics", + log_format='%(asctime)s - %(message)s') + + +def log_event(*, token_hash: str, action: str, project_id: str, user_email: str, user_id: str): + """ + Log Event for metrics + """ + try: + log_message = f"CSEL Token event token:{token_hash} " \ + f"{action} by prj:{project_id} " \ + f"usr:{user_email}:{user_id}" + + METRICS_LOG.info(log_message) + except Exception as e: + METRICS_LOG.error(f"Error occurred: {e}", stack_info=True) \ No newline at end of file diff --git a/fabric_cm/credmgr/logging/log.py b/fabric_cm/credmgr/logging/log.py deleted file mode 100644 index 31bcb08..0000000 --- a/fabric_cm/credmgr/logging/log.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -# MIT License -# -# Copyright (c) 2020 FABRIC Testbed -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# Author Komal Thareja (kthare10@renci.org) -""" -Provides logging functions -""" -import logging -import os -from logging.handlers import RotatingFileHandler - -from fabric_cm.credmgr.config import CONFIG_OBJ - - -def get_logger(): - """ - Detects the path and level for the log file from the credmgr CONFIG_OBJ and sets - up a logger. Instead of detecting the path and/or level from the - credmgr CONFIG_OBJ, a custom path and/or level for the log file can be passed as - optional arguments. - - :param log_path: Path to custom log file - :param log_level: Custom log level - :return: logging.Logger object - """ - - # Get the log path - log_path = CONFIG_OBJ.get_logger_dir() + '/' + CONFIG_OBJ.get_logger_file() - if log_path is None: - raise RuntimeError('The log file path must be specified in CONFIG_OBJ or passed as an argument') - - # Get the log level - log_level = CONFIG_OBJ.get_logger_level() - if log_level is None: - log_level = logging.INFO - - logger = CONFIG_OBJ.get_logger_name() - - # Set up the root logger - log = logging.getLogger(logger) - log.setLevel(log_level) - log_format = '%(asctime)s - %(name)s - {%(filename)s:%(lineno)d} - %(levelname)s - %(message)s' - os.makedirs(os.path.dirname(log_path), exist_ok=True) - - file_handler = RotatingFileHandler(log_path, - backupCount=CONFIG_OBJ.get_logger_retain(), - maxBytes=CONFIG_OBJ.get_logger_size()) - - logging.basicConfig(handlers=[file_handler], format=log_format) - - return log, file_handler diff --git a/fabric_cm/credmgr/logging/log_helper.py b/fabric_cm/credmgr/logging/log_helper.py new file mode 100644 index 0000000..dc8081c --- /dev/null +++ b/fabric_cm/credmgr/logging/log_helper.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2020 FABRIC Testbed +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Author Komal Thareja (kthare10@renci.org) +""" +Provides logging functions +""" +import logging +import os +from logging.handlers import RotatingFileHandler + + +class LogHelper: + @staticmethod + def make_logger(*, log_dir: str, log_file: str, log_level, log_retain: int, log_size: int, logger: str, + log_format: str = None): + """ + Detects the path and level for the log file from the actor config and sets + up a logger. Instead of detecting the path and/or level from the + config, a custom path and/or level for the log file can be passed as + optional arguments. + + :param log_dir: Log directory + :param log_file + :param log_level + :param log_retain + :param log_size + :param logger + :param log_format + :return: logging.Logger object + """ + log_path = f"{log_dir}/{log_file}" + + if log_path is None: + raise RuntimeError('The log file path must be specified in config or passed as an argument') + + if log_level is None: + log_level = logging.INFO + + # Set up the root logger + log = logging.getLogger(logger) + log.setLevel(log_level) + default_log_format = \ + '%(asctime)s - %(name)s - {%(filename)s:%(lineno)d} - [%(threadName)s]- %(levelname)s - %(message)s' + if log_format is not None: + default_log_format = log_format + + os.makedirs(os.path.dirname(log_path), exist_ok=True) + + file_handler = RotatingFileHandler(log_path, backupCount=int(log_retain), maxBytes=int(log_size)) + file_handler.setFormatter(logging.Formatter(default_log_format)) + log.addHandler(file_handler) + + return log diff --git a/fabric_cm/credmgr/openapi.json b/fabric_cm/credmgr/openapi.json index bd2d676..9b5f370 100644 --- a/fabric_cm/credmgr/openapi.json +++ b/fabric_cm/credmgr/openapi.json @@ -113,6 +113,17 @@ "type": "string" } }, + { + "name": "project_name", + "in": "query", + "description": "Project identified by name", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, { "name": "scope", "in": "query", @@ -124,15 +135,455 @@ "type": "string", "default": "all" } + }, + { + "name": "lifetime", + "in": "query", + "description": "Lifetime of the token requested in hours", + "required": false, + "style": "form", + "explode": true, + "schema": { + "maximum": 1512, + "type": "integer", + "default": 4 + } + }, + { + "name": "comment", + "in": "query", + "description": "Comment", + "required": false, + "style": "form", + "explode": true, + "schema": { + "maxLength": 100, + "minLength": 10, + "type": "string", + "default": "Create Token via GUI" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tokens" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_400_bad_request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_401_unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_403_forbidden" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_404_not_found" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_500_internal_server_error" + } + } + } + } + } + } + }, + "/tokens/refresh": { + "post": { + "tags": [ + "tokens" + ], + "summary": "Refresh tokens for an user", + "description": "Request to refresh OAuth tokens for an user\n", + "parameters": [ + { + "name": "project_id", + "in": "query", + "description": "Project identified by universally unique identifier", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_name", + "in": "query", + "description": "Project identified by name", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "description": "Scope for which token is requested", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string", + "default": "all" + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/payload_token_post" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tokens" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_400_bad_request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_401_unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_403_forbidden" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_404_not_found" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_500_internal_server_error" + } + } + } + } + } + } + }, + "/tokens/revokes": { + "post": { + "tags": [ + "tokens" + ], + "summary": "Revoke a token", + "description": "Request to revoke a token\n", + "requestBody": { + "$ref": "#/components/requestBodies/payload_token" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_200_ok_no_content" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_400_bad_request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_401_unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_403_forbidden" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_404_not_found" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_500_internal_server_error" + } + } + } + } + } + } + }, + "/tokens/revoke": { + "post": { + "tags": [ + "tokens" + ], + "summary": "Revoke a token for an user", + "description": "Request to revoke a token for an user\n", + "requestBody": { + "$ref": "#/components/requestBodies/payload_token_post" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_200_ok_no_content" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_400_bad_request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_401_unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_403_forbidden" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_404_not_found" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_500_internal_server_error" + } + } + } + } + } + } + }, + "/tokens/delete/{token_hash}": { + "delete": { + "tags": [ + "tokens" + ], + "summary": "Delete a token for an user", + "description": "Request to delete a token for an user\n", + "parameters": [ + { + "name": "token_hash", + "in": "path", + "description": "Token identified by SHA256 Hash", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_200_ok_no_content" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_400_bad_request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_401_unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_403_forbidden" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_404_not_found" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_500_internal_server_error" + } + } + } } + } + } + }, + "/tokens/delete": { + "delete": { + "tags": [ + "tokens" ], + "summary": "Delete all tokens for a user", + "description": "Request to delete all tokens for a user\n", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/tokens" + "$ref": "#/components/schemas/status_200_ok_no_content" } } } @@ -190,13 +641,87 @@ } } }, - "/tokens/refresh": { + "/tokens/validate": { "post": { "tags": [ "tokens" ], - "summary": "Refresh tokens for an user", - "description": "Request to refresh OAuth tokens for an user\n", + "summary": "Validate an identity token issued by Credential Manager", + "description": "Validate an identity token issued by Credential Manager\n", + "requestBody": { + "$ref": "#/components/requestBodies/payload_token" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/decoded_token" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_400_bad_request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_401_unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_403_forbidden" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_404_not_found" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/status_500_internal_server_error" + } + } + } + } + } + } + }, + "/tokens/revoke_list": { + "get": { + "tags": [ + "tokens" + ], + "summary": "Get token revoke list i.e. list of revoked identity token hashes", + "description": "Get token revoke list i.e. list of revoked identity token hashes for a user in a project\n", "parameters": [ { "name": "project_id", @@ -208,30 +733,15 @@ "schema": { "type": "string" } - }, - { - "name": "scope", - "in": "query", - "description": "Scope for which token is requested", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "default": "all" - } } ], - "requestBody": { - "$ref": "#/components/requestBodies/payload_token_post" - }, "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/tokens" + "$ref": "#/components/schemas/revoke_list" } } } @@ -289,23 +799,105 @@ } } }, - "/tokens/revoke": { - "post": { + "/tokens": { + "get": { "tags": [ "tokens" ], - "summary": "Revoke a refresh token for an user", - "description": "Request to revoke a refresh token for an user\n", - "requestBody": { - "$ref": "#/components/requestBodies/payload_token_post" - }, + "summary": "Get tokens", + "description": "Get tokens for a user in a project\n", + "parameters": [ + { + "name": "token_hash", + "in": "query", + "description": "Token identified by SHA256 hash", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project identified by universally unique identifier", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "expires", + "in": "query", + "description": "Search for tokens with expiry time lesser than the specified expiration time", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "string" + } + }, + { + "name": "states", + "in": "query", + "description": "Search for Tokens in the specified states", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "Nascent", + "Valid", + "Refreshed", + "Revoked", + "Expired" + ] + } + } + }, + { + "name": "limit", + "in": "query", + "description": "maximum number of results to return per page (1 or more)", + "required": false, + "style": "form", + "explode": true, + "schema": { + "maximum": 200, + "minimum": 1, + "type": "integer", + "format": "int32", + "default": 5 + } + }, + { + "name": "offset", + "in": "query", + "description": "number of items to skip before starting to collect the result set", + "required": false, + "style": "form", + "explode": true, + "schema": { + "minimum": 0, + "type": "integer", + "format": "int32", + "default": 0 + } + } + ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/status_200_ok_no_content" + "$ref": "#/components/schemas/tokens" } } } @@ -366,6 +958,27 @@ }, "components": { "schemas": { + "status_200_ok_paginated": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "status": { + "type": "integer", + "default": 200 + }, + "type": { + "type": "string" + } + } + }, "status_200_ok_single": { "type": "object", "properties": { @@ -596,12 +1209,47 @@ } } }, - "tokens": { + "revoke_list": { "type": "object", "allOf": [ { "$ref": "#/components/schemas/status_200_ok_single" }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "decoded_token": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/status_200_ok_no_content" + }, + { + "type": "object", + "properties": { + "token": { + "type": "object" + } + } + } + ] + }, + "tokens": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/status_200_ok_paginated" + }, { "type": "object", "properties": { @@ -618,11 +1266,44 @@ "token": { "required": [ "created_at", - "id_token", - "refresh_token" + "created_from", + "expires_at", + "state", + "token_hash" ], "type": "object", "properties": { + "token_hash": { + "type": "string", + "description": "Identity Token SHA256 Hash" + }, + "created_at": { + "type": "string", + "description": "Token creation time" + }, + "expires_at": { + "type": "string", + "description": "Token expiry time" + }, + "state": { + "type": "string", + "description": "Token state", + "enum": [ + "Nascent", + "Valid", + "Refreshed", + "Revoked", + "Expired" + ] + }, + "created_from": { + "type": "string", + "description": "Remote IP from where the token create request was received" + }, + "comment": { + "type": "string", + "description": "Comment provided at creation" + }, "id_token": { "type": "string", "description": "Identity Token" @@ -630,9 +1311,6 @@ "refresh_token": { "type": "string", "description": "Refresh Token" - }, - "created_at": { - "type": "string" } } }, @@ -648,6 +1326,27 @@ } } }, + "token_post": { + "required": [ + "token", + "type" + ], + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Token Type", + "enum": [ + "identity", + "refresh" + ] + }, + "token": { + "type": "string", + "description": "Refresh Token or Token Hash" + } + } + }, "version": { "type": "object", "allOf": [ @@ -721,8 +1420,7 @@ "type": "string", "description": "Key Id Header Parameter" } - }, - "example": null + } } }, "requestBodies": { @@ -735,6 +1433,16 @@ } }, "required": true + }, + "payload_token": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/token_post" + } + } + }, + "required": true } } } diff --git a/fabric_cm/credmgr/swagger_server/controllers/tokens_controller.py b/fabric_cm/credmgr/swagger_server/controllers/tokens_controller.py index e5a488b..e86c4ba 100644 --- a/fabric_cm/credmgr/swagger_server/controllers/tokens_controller.py +++ b/fabric_cm/credmgr/swagger_server/controllers/tokens_controller.py @@ -2,26 +2,82 @@ from fabric_cm.credmgr.swagger_server.models.request import Request # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status200_ok_no_content import Status200OkNoContent # noqa: E501 +from fabric_cm.credmgr.swagger_server.models.token_post import TokenPost from fabric_cm.credmgr.swagger_server.models.tokens import Tokens # noqa: E501 +from fabric_cm.credmgr.swagger_server.models.revoke_list import RevokeList # noqa: E501 from fabric_cm.credmgr.swagger_server.response import tokens_controller as rc -def tokens_create_post(project_id=None, scope=None): # noqa: E501 +def tokens_create_post(project_id=None, project_name=None, scope=None, lifetime=4, comment=None): # noqa: E501 """Generate tokens for an user Request to generate tokens for an user # noqa: E501 :param project_id: Project identified by universally unique identifier :type project_id: str + :param project_name: Project identified by name + :type project_name: str :param scope: Scope for which token is requested :type scope: str + :param lifetime: Lifetime of the token requested in hours + :type lifetime: int + :param comment: Comment + :type comment: str :rtype: Tokens """ - return rc.tokens_create_post(project_id, scope) + return rc.tokens_create_post(project_id, project_name, scope, lifetime, comment) -def tokens_refresh_post(body, project_id=None, scope=None): # noqa: E501 +def tokens_delete_delete(): # noqa: E501 + """Delete all tokens for a user + + Request to delete all tokens for a user # noqa: E501 + + + :rtype: Status200OkNoContent + """ + return rc.tokens_delete_delete() + + +def tokens_delete_token_hash_delete(token_hash): # noqa: E501 + """Delete a token for an user + + Request to delete a token for an user # noqa: E501 + + :param token_hash: Token identified by SHA256 Hash + :type token_hash: str + + :rtype: Status200OkNoContent + """ + return rc.tokens_delete_token_hash_delete(token_hash=token_hash) + + +def tokens_get(token_hash=None, project_id=None, expires=None, states=None, limit=None, offset=None): # noqa: E501 + """Get tokens + + Get tokens for a user in a project # noqa: E501 + + :param token_hash: Token identified by SHA256 hash + :type token_hash: str + :param project_id: Project identified by universally unique identifier + :type project_id: str + :param expires: Search for tokens with expiry time lesser than the specified expiration time + :type expires: str + :param states: Search for Tokens in the specified states + :type states: List[str] + :param limit: maximum number of results to return per page (1 or more) + :type limit: int + :param offset: number of items to skip before starting to collect the result set + :type offset: int + + :rtype: Tokens + """ + return rc.tokens_get(token_hash=token_hash, project_id=project_id, expires=expires, states=states, + limit=limit, offset=offset) + + +def tokens_refresh_post(body, project_id=None, project_name=None, scope=None): # noqa: E501 """Refresh tokens for an user Request to refresh OAuth tokens for an user # noqa: E501 @@ -30,6 +86,8 @@ def tokens_refresh_post(body, project_id=None, scope=None): # noqa: E501 :type body: dict | bytes :param project_id: Project identified by universally unique identifier :type project_id: str + :param project_name: Project identified by name + :type project_name: str :param scope: Scope for which token is requested :type scope: str @@ -37,13 +95,26 @@ def tokens_refresh_post(body, project_id=None, scope=None): # noqa: E501 """ if connexion.request.is_json: body = Request.from_dict(connexion.request.get_json()) # noqa: E501 - return rc.tokens_refresh_post(body, project_id, scope) + return rc.tokens_refresh_post(body, project_id, project_name, scope) + + +def tokens_revoke_list_get(project_id=None): # noqa: E501 + """Get token revoke list i.e. list of revoked identity token hashes + + Get token revoke list i.e. list of revoked identity token hashes for a user in a project # noqa: E501 + + :param project_id: Project identified by universally unique identifier + :type project_id: str + + :rtype: RevokeList + """ + return rc.tokens_revoke_list_get(project_id) def tokens_revoke_post(body): # noqa: E501 - """Revoke a refresh token for an user + """Revoke a token for an user - Request to revoke a refresh token for an user # noqa: E501 + Request to revoke a token for an user # noqa: E501 :param body: :type body: dict | bytes @@ -53,3 +124,34 @@ def tokens_revoke_post(body): # noqa: E501 if connexion.request.is_json: body = Request.from_dict(connexion.request.get_json()) # noqa: E501 return rc.tokens_revoke_post(body) + + +def tokens_revokes_post(body): # noqa: E501 + """Revoke a token + + Request to revoke a token # noqa: E501 + + :param body: + :type body: dict | bytes + + :rtype: Status200OkNoContent + """ + if connexion.request.is_json: + body = TokenPost.from_dict(connexion.request.get_json()) # noqa: E501 + return rc.tokens_revokes_post(body) + + +def tokens_validate_post(body): # noqa: E501 + """Validate an identity token issued by Credential Manager + + Validate an identity token issued by Credential Manager # noqa: E501 + + :param body: + :type body: dict | bytes + + :rtype: DecodedToken + """ + if connexion.request.is_json: + body = TokenPost.from_dict(connexion.request.get_json()) # noqa: E501 + return rc.tokens_validate_post(body) + diff --git a/fabric_cm/credmgr/swagger_server/models/__init__.py b/fabric_cm/credmgr/swagger_server/models/__init__.py index 114f5b8..627934b 100644 --- a/fabric_cm/credmgr/swagger_server/models/__init__.py +++ b/fabric_cm/credmgr/swagger_server/models/__init__.py @@ -3,11 +3,14 @@ # flake8: noqa from __future__ import absolute_import # import models into model package +from fabric_cm.credmgr.swagger_server.models.decoded_token import DecodedToken from fabric_cm.credmgr.swagger_server.models.jwks import Jwks from fabric_cm.credmgr.swagger_server.models.jwks_keys import JwksKeys from fabric_cm.credmgr.swagger_server.models.request import Request +from fabric_cm.credmgr.swagger_server.models.revoke_list import RevokeList from fabric_cm.credmgr.swagger_server.models.status200_ok_no_content import Status200OkNoContent from fabric_cm.credmgr.swagger_server.models.status200_ok_no_content_data import Status200OkNoContentData +from fabric_cm.credmgr.swagger_server.models.status200_ok_paginated import Status200OkPaginated from fabric_cm.credmgr.swagger_server.models.status200_ok_single import Status200OkSingle from fabric_cm.credmgr.swagger_server.models.status400_bad_request import Status400BadRequest from fabric_cm.credmgr.swagger_server.models.status400_bad_request_errors import Status400BadRequestErrors @@ -20,6 +23,7 @@ from fabric_cm.credmgr.swagger_server.models.status500_internal_server_error import Status500InternalServerError from fabric_cm.credmgr.swagger_server.models.status500_internal_server_error_errors import Status500InternalServerErrorErrors from fabric_cm.credmgr.swagger_server.models.token import Token +from fabric_cm.credmgr.swagger_server.models.token_post import TokenPost from fabric_cm.credmgr.swagger_server.models.tokens import Tokens from fabric_cm.credmgr.swagger_server.models.version import Version from fabric_cm.credmgr.swagger_server.models.version_data import VersionData diff --git a/fabric_cm/credmgr/swagger_server/models/decoded_token.py b/fabric_cm/credmgr/swagger_server/models/decoded_token.py new file mode 100644 index 0000000..c4b69de --- /dev/null +++ b/fabric_cm/credmgr/swagger_server/models/decoded_token.py @@ -0,0 +1,168 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from fabric_cm.credmgr.swagger_server.models.base_model_ import Model +from fabric_cm.credmgr.swagger_server.models.status200_ok_no_content import Status200OkNoContent # noqa: F401,E501 +from fabric_cm.credmgr.swagger_server.models.status200_ok_no_content_data import Status200OkNoContentData # noqa: F401,E501 +from fabric_cm.credmgr.swagger_server import util + + +class DecodedToken(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, data: List[Status200OkNoContentData]=None, type: str='no_content', size: int=1, status: int=200, token: object=None): # noqa: E501 + """DecodedToken - a model defined in Swagger + + :param data: The data of this DecodedToken. # noqa: E501 + :type data: List[Status200OkNoContentData] + :param type: The type of this DecodedToken. # noqa: E501 + :type type: str + :param size: The size of this DecodedToken. # noqa: E501 + :type size: int + :param status: The status of this DecodedToken. # noqa: E501 + :type status: int + :param token: The token of this DecodedToken. # noqa: E501 + :type token: object + """ + self.swagger_types = { + 'data': List[Status200OkNoContentData], + 'type': str, + 'size': int, + 'status': int, + 'token': object + } + + self.attribute_map = { + 'data': 'data', + 'type': 'type', + 'size': 'size', + 'status': 'status', + 'token': 'token' + } + self._data = data + self._type = type + self._size = size + self._status = status + self._token = token + + @classmethod + def from_dict(cls, dikt) -> 'DecodedToken': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The decoded_token of this DecodedToken. # noqa: E501 + :rtype: DecodedToken + """ + return util.deserialize_model(dikt, cls) + + @property + def data(self) -> List[Status200OkNoContentData]: + """Gets the data of this DecodedToken. + + + :return: The data of this DecodedToken. + :rtype: List[Status200OkNoContentData] + """ + return self._data + + @data.setter + def data(self, data: List[Status200OkNoContentData]): + """Sets the data of this DecodedToken. + + + :param data: The data of this DecodedToken. + :type data: List[Status200OkNoContentData] + """ + + self._data = data + + @property + def type(self) -> str: + """Gets the type of this DecodedToken. + + + :return: The type of this DecodedToken. + :rtype: str + """ + return self._type + + @type.setter + def type(self, type: str): + """Sets the type of this DecodedToken. + + + :param type: The type of this DecodedToken. + :type type: str + """ + + self._type = type + + @property + def size(self) -> int: + """Gets the size of this DecodedToken. + + + :return: The size of this DecodedToken. + :rtype: int + """ + return self._size + + @size.setter + def size(self, size: int): + """Sets the size of this DecodedToken. + + + :param size: The size of this DecodedToken. + :type size: int + """ + + self._size = size + + @property + def status(self) -> int: + """Gets the status of this DecodedToken. + + + :return: The status of this DecodedToken. + :rtype: int + """ + return self._status + + @status.setter + def status(self, status: int): + """Sets the status of this DecodedToken. + + + :param status: The status of this DecodedToken. + :type status: int + """ + + self._status = status + + @property + def token(self) -> object: + """Gets the token of this DecodedToken. + + + :return: The token of this DecodedToken. + :rtype: object + """ + return self._token + + @token.setter + def token(self, token: object): + """Sets the token of this DecodedToken. + + + :param token: The token of this DecodedToken. + :type token: object + """ + + self._token = token diff --git a/fabric_cm/credmgr/swagger_server/models/revoke_list.py b/fabric_cm/credmgr/swagger_server/models/revoke_list.py new file mode 100644 index 0000000..df862ec --- /dev/null +++ b/fabric_cm/credmgr/swagger_server/models/revoke_list.py @@ -0,0 +1,141 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from fabric_cm.credmgr.swagger_server.models.base_model_ import Model +from fabric_cm.credmgr.swagger_server.models.status200_ok_single import Status200OkSingle # noqa: F401,E501 +from fabric_cm.credmgr.swagger_server import util + + +class RevokeList(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, size: int=1, status: int=200, type: str=None, data: List[str]=None): # noqa: E501 + """RevokeList - a model defined in Swagger + + :param size: The size of this RevokeList. # noqa: E501 + :type size: int + :param status: The status of this RevokeList. # noqa: E501 + :type status: int + :param type: The type of this RevokeList. # noqa: E501 + :type type: str + :param data: The data of this RevokeList. # noqa: E501 + :type data: List[str] + """ + self.swagger_types = { + 'size': int, + 'status': int, + 'type': str, + 'data': List[str] + } + + self.attribute_map = { + 'size': 'size', + 'status': 'status', + 'type': 'type', + 'data': 'data' + } + self._size = size + self._status = status + self._type = type + self._data = data + + @classmethod + def from_dict(cls, dikt) -> 'RevokeList': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The revoke_list of this RevokeList. # noqa: E501 + :rtype: RevokeList + """ + return util.deserialize_model(dikt, cls) + + @property + def size(self) -> int: + """Gets the size of this RevokeList. + + + :return: The size of this RevokeList. + :rtype: int + """ + return self._size + + @size.setter + def size(self, size: int): + """Sets the size of this RevokeList. + + + :param size: The size of this RevokeList. + :type size: int + """ + + self._size = size + + @property + def status(self) -> int: + """Gets the status of this RevokeList. + + + :return: The status of this RevokeList. + :rtype: int + """ + return self._status + + @status.setter + def status(self, status: int): + """Sets the status of this RevokeList. + + + :param status: The status of this RevokeList. + :type status: int + """ + + self._status = status + + @property + def type(self) -> str: + """Gets the type of this RevokeList. + + + :return: The type of this RevokeList. + :rtype: str + """ + return self._type + + @type.setter + def type(self, type: str): + """Sets the type of this RevokeList. + + + :param type: The type of this RevokeList. + :type type: str + """ + + self._type = type + + @property + def data(self) -> List[str]: + """Gets the data of this RevokeList. + + + :return: The data of this RevokeList. + :rtype: List[str] + """ + return self._data + + @data.setter + def data(self, data: List[str]): + """Sets the data of this RevokeList. + + + :param data: The data of this RevokeList. + :type data: List[str] + """ + + self._data = data diff --git a/fabric_cm/credmgr/swagger_server/models/status200_ok_paginated.py b/fabric_cm/credmgr/swagger_server/models/status200_ok_paginated.py new file mode 100644 index 0000000..9734351 --- /dev/null +++ b/fabric_cm/credmgr/swagger_server/models/status200_ok_paginated.py @@ -0,0 +1,166 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from fabric_cm.credmgr.swagger_server.models.base_model_ import Model +from fabric_cm.credmgr.swagger_server import util + + +class Status200OkPaginated(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, limit: int=None, offset: int=None, size: int=None, status: int=200, type: str=None): # noqa: E501 + """Status200OkPaginated - a model defined in Swagger + + :param limit: The limit of this Status200OkPaginated. # noqa: E501 + :type limit: int + :param offset: The offset of this Status200OkPaginated. # noqa: E501 + :type offset: int + :param size: The size of this Status200OkPaginated. # noqa: E501 + :type size: int + :param status: The status of this Status200OkPaginated. # noqa: E501 + :type status: int + :param type: The type of this Status200OkPaginated. # noqa: E501 + :type type: str + """ + self.swagger_types = { + 'limit': int, + 'offset': int, + 'size': int, + 'status': int, + 'type': str + } + + self.attribute_map = { + 'limit': 'limit', + 'offset': 'offset', + 'size': 'size', + 'status': 'status', + 'type': 'type' + } + self._limit = limit + self._offset = offset + self._size = size + self._status = status + self._type = type + + @classmethod + def from_dict(cls, dikt) -> 'Status200OkPaginated': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The status_200_ok_paginated of this Status200OkPaginated. # noqa: E501 + :rtype: Status200OkPaginated + """ + return util.deserialize_model(dikt, cls) + + @property + def limit(self) -> int: + """Gets the limit of this Status200OkPaginated. + + + :return: The limit of this Status200OkPaginated. + :rtype: int + """ + return self._limit + + @limit.setter + def limit(self, limit: int): + """Sets the limit of this Status200OkPaginated. + + + :param limit: The limit of this Status200OkPaginated. + :type limit: int + """ + + self._limit = limit + + @property + def offset(self) -> int: + """Gets the offset of this Status200OkPaginated. + + + :return: The offset of this Status200OkPaginated. + :rtype: int + """ + return self._offset + + @offset.setter + def offset(self, offset: int): + """Sets the offset of this Status200OkPaginated. + + + :param offset: The offset of this Status200OkPaginated. + :type offset: int + """ + + self._offset = offset + + @property + def size(self) -> int: + """Gets the size of this Status200OkPaginated. + + + :return: The size of this Status200OkPaginated. + :rtype: int + """ + return self._size + + @size.setter + def size(self, size: int): + """Sets the size of this Status200OkPaginated. + + + :param size: The size of this Status200OkPaginated. + :type size: int + """ + + self._size = size + + @property + def status(self) -> int: + """Gets the status of this Status200OkPaginated. + + + :return: The status of this Status200OkPaginated. + :rtype: int + """ + return self._status + + @status.setter + def status(self, status: int): + """Sets the status of this Status200OkPaginated. + + + :param status: The status of this Status200OkPaginated. + :type status: int + """ + + self._status = status + + @property + def type(self) -> str: + """Gets the type of this Status200OkPaginated. + + + :return: The type of this Status200OkPaginated. + :rtype: str + """ + return self._type + + @type.setter + def type(self, type: str): + """Sets the type of this Status200OkPaginated. + + + :param type: The type of this Status200OkPaginated. + :type type: str + """ + + self._type = type diff --git a/fabric_cm/credmgr/swagger_server/models/token.py b/fabric_cm/credmgr/swagger_server/models/token.py index f7978fa..f4a3822 100644 --- a/fabric_cm/credmgr/swagger_server/models/token.py +++ b/fabric_cm/credmgr/swagger_server/models/token.py @@ -14,30 +14,55 @@ class Token(Model): Do not edit the class manually. """ - def __init__(self, id_token: str=None, refresh_token: str=None, created_at: str=None): # noqa: E501 + def __init__(self, token_hash: str=None, created_at: str=None, expires_at: str=None, state: str=None, created_from: str=None, comment: str=None, id_token: str=None, refresh_token: str=None): # noqa: E501 """Token - a model defined in Swagger + :param token_hash: The token_hash of this Token. # noqa: E501 + :type token_hash: str + :param created_at: The created_at of this Token. # noqa: E501 + :type created_at: str + :param expires_at: The expires_at of this Token. # noqa: E501 + :type expires_at: str + :param state: The state of this Token. # noqa: E501 + :type state: str + :param created_from: The created_from of this Token. # noqa: E501 + :type created_from: str + :param comment: The comment of this Token. # noqa: E501 + :type comment: str :param id_token: The id_token of this Token. # noqa: E501 :type id_token: str :param refresh_token: The refresh_token of this Token. # noqa: E501 :type refresh_token: str - :param created_at: The created_at of this Token. # noqa: E501 - :type created_at: str """ self.swagger_types = { + 'token_hash': str, + 'created_at': str, + 'expires_at': str, + 'state': str, + 'created_from': str, + 'comment': str, 'id_token': str, - 'refresh_token': str, - 'created_at': str + 'refresh_token': str } self.attribute_map = { + 'token_hash': 'token_hash', + 'created_at': 'created_at', + 'expires_at': 'expires_at', + 'state': 'state', + 'created_from': 'created_from', + 'comment': 'comment', 'id_token': 'id_token', - 'refresh_token': 'refresh_token', - 'created_at': 'created_at' + 'refresh_token': 'refresh_token' } + self._token_hash = token_hash + self._created_at = created_at + self._expires_at = expires_at + self._state = state + self._created_from = created_from + self._comment = comment self._id_token = id_token self._refresh_token = refresh_token - self._created_at = created_at @classmethod def from_dict(cls, dikt) -> 'Token': @@ -50,6 +75,158 @@ def from_dict(cls, dikt) -> 'Token': """ return util.deserialize_model(dikt, cls) + @property + def token_hash(self) -> str: + """Gets the token_hash of this Token. + + Identity Token SHA256 Hash # noqa: E501 + + :return: The token_hash of this Token. + :rtype: str + """ + return self._token_hash + + @token_hash.setter + def token_hash(self, token_hash: str): + """Sets the token_hash of this Token. + + Identity Token SHA256 Hash # noqa: E501 + + :param token_hash: The token_hash of this Token. + :type token_hash: str + """ + if token_hash is None: + raise ValueError("Invalid value for `token_hash`, must not be `None`") # noqa: E501 + + self._token_hash = token_hash + + @property + def created_at(self) -> str: + """Gets the created_at of this Token. + + Token creation time # noqa: E501 + + :return: The created_at of this Token. + :rtype: str + """ + return self._created_at + + @created_at.setter + def created_at(self, created_at: str): + """Sets the created_at of this Token. + + Token creation time # noqa: E501 + + :param created_at: The created_at of this Token. + :type created_at: str + """ + if created_at is None: + raise ValueError("Invalid value for `created_at`, must not be `None`") # noqa: E501 + + self._created_at = created_at + + @property + def expires_at(self) -> str: + """Gets the expires_at of this Token. + + Token expiry time # noqa: E501 + + :return: The expires_at of this Token. + :rtype: str + """ + return self._expires_at + + @expires_at.setter + def expires_at(self, expires_at: str): + """Sets the expires_at of this Token. + + Token expiry time # noqa: E501 + + :param expires_at: The expires_at of this Token. + :type expires_at: str + """ + if expires_at is None: + raise ValueError("Invalid value for `expires_at`, must not be `None`") # noqa: E501 + + self._expires_at = expires_at + + @property + def state(self) -> str: + """Gets the state of this Token. + + Token state # noqa: E501 + + :return: The state of this Token. + :rtype: str + """ + return self._state + + @state.setter + def state(self, state: str): + """Sets the state of this Token. + + Token state # noqa: E501 + + :param state: The state of this Token. + :type state: str + """ + allowed_values = ["Nascent", "Valid", "Refreshed", "Revoked", "Expired"] # noqa: E501 + if state not in allowed_values: + raise ValueError( + "Invalid value for `state` ({0}), must be one of {1}" + .format(state, allowed_values) + ) + + self._state = state + + @property + def created_from(self) -> str: + """Gets the created_from of this Token. + + Remote IP from where the token create request was received # noqa: E501 + + :return: The created_from of this Token. + :rtype: str + """ + return self._created_from + + @created_from.setter + def created_from(self, created_from: str): + """Sets the created_from of this Token. + + Remote IP from where the token create request was received # noqa: E501 + + :param created_from: The created_from of this Token. + :type created_from: str + """ + if created_from is None: + raise ValueError("Invalid value for `created_from`, must not be `None`") # noqa: E501 + + self._created_from = created_from + + @property + def comment(self) -> str: + """Gets the comment of this Token. + + Comment provided at creation # noqa: E501 + + :return: The comment of this Token. + :rtype: str + """ + return self._comment + + @comment.setter + def comment(self, comment: str): + """Sets the comment of this Token. + + Comment provided at creation # noqa: E501 + + :param comment: The comment of this Token. + :type comment: str + """ + + self._comment = comment + @property def id_token(self) -> str: """Gets the id_token of this Token. @@ -70,8 +247,6 @@ def id_token(self, id_token: str): :param id_token: The id_token of this Token. :type id_token: str """ - if id_token is None: - raise ValueError("Invalid value for `id_token`, must not be `None`") # noqa: E501 self._id_token = id_token @@ -95,30 +270,5 @@ def refresh_token(self, refresh_token: str): :param refresh_token: The refresh_token of this Token. :type refresh_token: str """ - if refresh_token is None: - raise ValueError("Invalid value for `refresh_token`, must not be `None`") # noqa: E501 self._refresh_token = refresh_token - - @property - def created_at(self) -> str: - """Gets the created_at of this Token. - - - :return: The created_at of this Token. - :rtype: str - """ - return self._created_at - - @created_at.setter - def created_at(self, created_at: str): - """Sets the created_at of this Token. - - - :param created_at: The created_at of this Token. - :type created_at: str - """ - if created_at is None: - raise ValueError("Invalid value for `created_at`, must not be `None`") # noqa: E501 - - self._created_at = created_at diff --git a/fabric_cm/credmgr/swagger_server/models/token_post.py b/fabric_cm/credmgr/swagger_server/models/token_post.py new file mode 100644 index 0000000..1d5078f --- /dev/null +++ b/fabric_cm/credmgr/swagger_server/models/token_post.py @@ -0,0 +1,100 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from fabric_cm.credmgr.swagger_server.models.base_model_ import Model +from fabric_cm.credmgr.swagger_server import util + + +class TokenPost(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, type: str=None, token: str=None): # noqa: E501 + """TokenPost - a model defined in Swagger + + :param type: The type of this TokenPost. # noqa: E501 + :type type: str + :param token: The token of this TokenPost. # noqa: E501 + :type token: str + """ + self.swagger_types = { + 'type': str, + 'token': str + } + + self.attribute_map = { + 'type': 'type', + 'token': 'token' + } + self._type = type + self._token = token + + @classmethod + def from_dict(cls, dikt) -> 'TokenPost': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The token_post of this TokenPost. # noqa: E501 + :rtype: TokenPost + """ + return util.deserialize_model(dikt, cls) + + @property + def type(self) -> str: + """Gets the type of this TokenPost. + + Token Type # noqa: E501 + + :return: The type of this TokenPost. + :rtype: str + """ + return self._type + + @type.setter + def type(self, type: str): + """Sets the type of this TokenPost. + + Token Type # noqa: E501 + + :param type: The type of this TokenPost. + :type type: str + """ + allowed_values = ["identity", "refresh"] # noqa: E501 + if type not in allowed_values: + raise ValueError( + "Invalid value for `type` ({0}), must be one of {1}" + .format(type, allowed_values) + ) + + self._type = type + + @property + def token(self) -> str: + """Gets the token of this TokenPost. + + Refresh Token or Token Hash # noqa: E501 + + :return: The token of this TokenPost. + :rtype: str + """ + return self._token + + @token.setter + def token(self, token: str): + """Sets the token of this TokenPost. + + Refresh Token or Token Hash # noqa: E501 + + :param token: The token of this TokenPost. + :type token: str + """ + if token is None: + raise ValueError("Invalid value for `token`, must not be `None`") # noqa: E501 + + self._token = token diff --git a/fabric_cm/credmgr/swagger_server/models/tokens.py b/fabric_cm/credmgr/swagger_server/models/tokens.py index 2276965..b048e25 100644 --- a/fabric_cm/credmgr/swagger_server/models/tokens.py +++ b/fabric_cm/credmgr/swagger_server/models/tokens.py @@ -6,7 +6,7 @@ from typing import List, Dict # noqa: F401 from fabric_cm.credmgr.swagger_server.models.base_model_ import Model -from fabric_cm.credmgr.swagger_server.models.status200_ok_single import Status200OkSingle # noqa: F401,E501 +from fabric_cm.credmgr.swagger_server.models.status200_ok_paginated import Status200OkPaginated # noqa: F401,E501 from fabric_cm.credmgr.swagger_server.models.token import Token # noqa: F401,E501 from fabric_cm.credmgr.swagger_server import util @@ -16,9 +16,13 @@ class Tokens(Model): Do not edit the class manually. """ - def __init__(self, size: int=1, status: int=200, type: str=None, data: List[Token]=None): # noqa: E501 + def __init__(self, limit: int=None, offset: int=None, size: int=None, status: int=200, type: str=None, data: List[Token]=None): # noqa: E501 """Tokens - a model defined in Swagger + :param limit: The limit of this Tokens. # noqa: E501 + :type limit: int + :param offset: The offset of this Tokens. # noqa: E501 + :type offset: int :param size: The size of this Tokens. # noqa: E501 :type size: int :param status: The status of this Tokens. # noqa: E501 @@ -29,6 +33,8 @@ def __init__(self, size: int=1, status: int=200, type: str=None, data: List[Toke :type data: List[Token] """ self.swagger_types = { + 'limit': int, + 'offset': int, 'size': int, 'status': int, 'type': str, @@ -36,11 +42,15 @@ def __init__(self, size: int=1, status: int=200, type: str=None, data: List[Toke } self.attribute_map = { + 'limit': 'limit', + 'offset': 'offset', 'size': 'size', 'status': 'status', 'type': 'type', 'data': 'data' } + self._limit = limit + self._offset = offset self._size = size self._status = status self._type = type @@ -57,6 +67,48 @@ def from_dict(cls, dikt) -> 'Tokens': """ return util.deserialize_model(dikt, cls) + @property + def limit(self) -> int: + """Gets the limit of this Tokens. + + + :return: The limit of this Tokens. + :rtype: int + """ + return self._limit + + @limit.setter + def limit(self, limit: int): + """Sets the limit of this Tokens. + + + :param limit: The limit of this Tokens. + :type limit: int + """ + + self._limit = limit + + @property + def offset(self) -> int: + """Gets the offset of this Tokens. + + + :return: The offset of this Tokens. + :rtype: int + """ + return self._offset + + @offset.setter + def offset(self, offset: int): + """Sets the offset of this Tokens. + + + :param offset: The offset of this Tokens. + :type offset: int + """ + + self._offset = offset + @property def size(self) -> int: """Gets the size of this Tokens. diff --git a/fabric_cm/credmgr/swagger_server/response/constants.py b/fabric_cm/credmgr/swagger_server/response/constants.py index b39c7ed..50fc5d9 100644 --- a/fabric_cm/credmgr/swagger_server/response/constants.py +++ b/fabric_cm/credmgr/swagger_server/response/constants.py @@ -27,9 +27,15 @@ TOKENS_CREATE_URL = '/tokens/create' TOKENS_REFRESH_URL = '/tokens/refresh' TOKENS_REVOKE_URL = '/tokens/revoke' +TOKENS_REVOKES_URL = '/tokens/revokes' +TOKENS_REVOKE_LIST_URL = '/tokens/revoke_list' +TOKENS_VALIDATE_URL = '/tokens/validate' +TOKENS_DELETE_URL = '/tokens/delete' +TOKENS_DELETE_TOKEN_HASH_URL = '/tokens/delete/{token_hash}' HTTP_METHOD_GET = 'get' HTTP_METHOD_POST = 'post' +HTTP_METHOD_DELETE = 'delete' VOUCH_ID_TOKEN = 'X-Vouch-Idp-IdToken' diff --git a/fabric_cm/credmgr/swagger_server/response/cors_response.py b/fabric_cm/credmgr/swagger_server/response/cors_response.py index 50bfa49..ce68b5c 100644 --- a/fabric_cm/credmgr/swagger_server/response/cors_response.py +++ b/fabric_cm/credmgr/swagger_server/response/cors_response.py @@ -8,7 +8,7 @@ from fabric_cm.credmgr.swagger_server.models import Tokens, Version, Status200OkNoContent, \ Status200OkNoContentData, Status400BadRequestErrors, Status400BadRequest, Status401UnauthorizedErrors, \ Status401Unauthorized, Status403ForbiddenErrors, Status403Forbidden, Status404NotFoundErrors, Status404NotFound, \ - Status500InternalServerErrorErrors, Status500InternalServerError + Status500InternalServerErrorErrors, Status500InternalServerError, RevokeList, DecodedToken _INDENT = int(os.getenv('OC_API_JSON_RESPONSE_INDENT', '4')) @@ -43,11 +43,10 @@ def cors_response(req: request, status_code: int = 200, body: object = None, x_e response.headers['Access-Control-Allow-Headers'] = \ 'DNT, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Range, Authorization' response.headers['Access-Control-Expose-Headers'] = 'Content-Length, Content-Range' - return response -def cors_200(response_body: Union[Tokens, Version, Status200OkNoContent] = None) -> cors_response: +def cors_200(response_body: Union[Tokens, Version, Status200OkNoContent, DecodedToken, RevokeList] = None) -> cors_response: """ Return 200 - OK """ diff --git a/fabric_cm/credmgr/swagger_server/response/decorators.py b/fabric_cm/credmgr/swagger_server/response/decorators.py new file mode 100644 index 0000000..70e322c --- /dev/null +++ b/fabric_cm/credmgr/swagger_server/response/decorators.py @@ -0,0 +1,121 @@ +from functools import wraps +from typing import Union + +from fabric_cm.credmgr.common.utils import Utils +from fabric_cm.credmgr.core.oauth_credmgr import OAuthCredMgr, TokenState +from fabric_cm.credmgr.swagger_server import jwt_validator +from fss_utils.jwt_manager import JWTManager, ValidateCode + +from fabric_cm.credmgr.config import CONFIG_OBJ + +from fabric_cm.credmgr.swagger_server.response.constants import VOUCH_ID_TOKEN, VOUCH_REFRESH_TOKEN + +from fabric_cm.credmgr.swagger_server.response.cors_response import cors_401 + +from fabric_cm.credmgr.logging import LOG +from flask import request + +EMAIL = "email" + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if CONFIG_OBJ.get_vouch_cookie_name() not in request.cookies: + details = 'Login required' + LOG.info(f"login_required(): {details}") + return cors_401(details=details) + claims = vouch_authorize() + if claims is None: + details = 'Cookie signature has expired' + LOG.info(f"login_required(): {details}") + return cors_401(details=details) + + return f(*args, claims=claims, **kwargs) + + return decorated_function + + +def login_or_token_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'authorization' in [h.casefold() for h in request.headers.keys()]: + claims = validate_authorization_token(request.headers.get('authorization')) + if isinstance(claims, dict): + return f(*args, claims=claims, **kwargs) + else: + details = f'Login or Token required : {claims}' + LOG.info(f"login_or_token_required(): {details}") + return cors_401(details=details) + if CONFIG_OBJ.get_vouch_cookie_name() not in request.cookies: + details = 'Login or Token required' + LOG.info(f"login_or_token_required(): {details}") + return cors_401(details=details) + claims = vouch_authorize() + if claims is None: + details = 'Cookie signature has expired' + LOG.info(f"login_or_token_required(): {details}") + return cors_401(details=details) + + return f(*args, claims=claims, **kwargs) + + return decorated_function + + +def vouch_authorize() -> Union[dict, None]: + """ + Decode vouch cookie and extract identity and refresh tokens + @return tuple containing ci logon identity token, refresh token and cookie + """ + ci_logon_id_token = request.headers.get(VOUCH_ID_TOKEN, None) + refresh_token = request.headers.get(VOUCH_REFRESH_TOKEN, None) + cookie_name = CONFIG_OBJ.get_vouch_cookie_name() + cookie = request.cookies.get(cookie_name) + if ci_logon_id_token is None and refresh_token is None and cookie is not None: + vouch_secret = CONFIG_OBJ.get_vouch_secret() + vouch_compression = CONFIG_OBJ.is_vouch_cookie_compressed() + status, decoded_cookie = JWTManager.decode(cookie=cookie,secret=vouch_secret, + compression=vouch_compression, verify=False) + if status == ValidateCode.VALID: + ci_logon_id_token = decoded_cookie.get('PIdToken') + refresh_token = decoded_cookie.get('PRefreshToken') + + if ci_logon_id_token is not None and refresh_token is not None and cookie is not None: + code, claims_or_exception = jwt_validator.validate_jwt(token=ci_logon_id_token) + if code is not ValidateCode.VALID: + LOG.error(f"Unable to validate provided token: {code}/{claims_or_exception}") + return None + + result = {OAuthCredMgr.REFRESH_TOKEN: refresh_token, + OAuthCredMgr.ID_TOKEN: ci_logon_id_token, + OAuthCredMgr.COOKIE: cookie} + for key, value in claims_or_exception.items(): + result[key] = value + + if result.get(EMAIL) is None: + result[EMAIL] = Utils.get_user_email(cookie=cookie) + return result + + +def validate_authorization_token(token: str) -> Union[dict, str]: + """ + Validate that the API has fabric token and the token is valid + @return returns the decoded claims + """ + if token is not None: + try: + token = token.replace('Bearer ', '') + LOG.info("Validating Fabric token") + credmgr = OAuthCredMgr() + state, claims = credmgr.validate_token(token=token) + + if state not in [str(TokenState.Valid), str(TokenState.Refreshed)]: + msg = f"Unable to validate provided token: {state} claims:{claims}" + LOG.error(msg) + return msg + return claims + except Exception as e: + msg = f"Unable to validate provided token e: {e}!" + LOG.error(msg, stack_info=True) + return msg + diff --git a/fabric_cm/credmgr/swagger_server/response/tokens_controller.py b/fabric_cm/credmgr/swagger_server/response/tokens_controller.py index 1dc303a..1d69722 100644 --- a/fabric_cm/credmgr/swagger_server/response/tokens_controller.py +++ b/fabric_cm/credmgr/swagger_server/response/tokens_controller.py @@ -22,67 +22,60 @@ # SOFTWARE. # # Author Komal Thareja (kthare10@renci.org) -""" -Module for handling /tokens APIs -""" -from http.client import INTERNAL_SERVER_ERROR +from datetime import datetime import connexion -from fss_utils.jwt_manager import JWTManager, ValidateCode from oauthlib.oauth2.rfc6749.errors import CustomOAuth2Error -from fabric_cm.credmgr.credential_managers.oauth_credmgr import OAuthCredmgr -from fabric_cm.credmgr.swagger_server.models import Tokens, Token, Status200OkNoContent, Status200OkNoContentData +from fabric_cm.credmgr.core.oauth_credmgr import OAuthCredMgr, TokenState +from fabric_cm.credmgr.swagger_server.models import Tokens, Token, Status200OkNoContent, Status200OkNoContentData, \ + RevokeList, DecodedToken from fabric_cm.credmgr.swagger_server.models.request import Request # noqa: E501 from fabric_cm.credmgr.swagger_server import received_counter, success_counter, failure_counter +from fabric_cm.credmgr.swagger_server.models.token_post import TokenPost from fabric_cm.credmgr.swagger_server.response.constants import HTTP_METHOD_POST, TOKENS_REVOKE_URL, \ - TOKENS_REFRESH_URL, TOKENS_CREATE_URL, VOUCH_ID_TOKEN, VOUCH_REFRESH_TOKEN -from fabric_cm.credmgr.config import CONFIG_OBJ + TOKENS_REFRESH_URL, TOKENS_CREATE_URL, TOKENS_REVOKES_URL, HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL, \ + TOKENS_VALIDATE_URL, TOKENS_DELETE_URL, TOKENS_DELETE_TOKEN_HASH_URL, HTTP_METHOD_DELETE from fabric_cm.credmgr.logging import LOG -from fabric_cm.credmgr.swagger_server.response.cors_response import cors_401, cors_200, cors_500 +from fabric_cm.credmgr.swagger_server.response.cors_response import cors_200, cors_500, cors_400 +from fabric_cm.credmgr.swagger_server.response.decorators import login_required, login_or_token_required -def authorize(request): - ci_logon_id_token = request.headers.get(VOUCH_ID_TOKEN, None) - refresh_token = request.headers.get(VOUCH_REFRESH_TOKEN, None) - cookie_name = CONFIG_OBJ.get_vouch_cookie_name() - cookie = request.cookies.get(cookie_name) - if ci_logon_id_token is None and refresh_token is None and cookie is not None: - vouch_secret = CONFIG_OBJ.get_vouch_secret() - vouch_compression = CONFIG_OBJ.is_vouch_cookie_compressed() - status, decoded_cookie = JWTManager.decode(cookie=cookie,secret=vouch_secret, - compression=vouch_compression, verify=False) - if status == ValidateCode.VALID: - ci_logon_id_token = decoded_cookie.get('PIdToken', None) - refresh_token = decoded_cookie.get('PRefreshToken', None) - - return ci_logon_id_token, refresh_token, cookie - - -def tokens_create_post(project_id, scope=None): # noqa: E501 +@login_required +def tokens_create_post(project_id: str, project_name: str, scope: str = None, lifetime: int = 4, comment: str = None, + claims: dict = None): # noqa: E501 """Generate Fabric OAuth tokens for an user Request to generate Fabric OAuth tokens for an user # noqa: E501 :param project_id: Project Id :type project_id: str + :param project_name: Project identified by name + :type project_name: str :param scope: Scope for which token is requested :type scope: str + :param lifetime: Lifetime of the token requested in hours + :type lifetime: int + :param comment: Comment + :type comment: str + :param claims: claims + :type claims: dict :rtype: Success """ received_counter.labels(HTTP_METHOD_POST, TOKENS_CREATE_URL).inc() try: - ci_logon_id_token, refresh_token, cookie = authorize(connexion.request) - if ci_logon_id_token is None: - return cors_401(details="No CI Logon Id Token in the request") - - credmgr = OAuthCredmgr() - token_dict = credmgr.create_token(ci_logon_id_token=ci_logon_id_token, - refresh_token=refresh_token, - project=project_id, - scope=scope, - cookie=cookie) + credmgr = OAuthCredMgr() + remote_addr = connexion.request.remote_addr + if connexion.request.headers.get('X-Real-IP') is not None: + remote_addr = connexion.request.headers.get('X-Real-IP') + token_dict = credmgr.create_token(ci_logon_id_token=claims.get(OAuthCredMgr.ID_TOKEN), + refresh_token=claims.get(OAuthCredMgr.REFRESH_TOKEN), + cookie=claims.get(OAuthCredMgr.COOKIE), + project_id=project_id, project_name=project_name, + scope=scope, lifetime=lifetime, + comment=comment, remote_addr=remote_addr, + user_email=claims.get(OAuthCredMgr.EMAIL)) response = Tokens() token = Token().from_dict(token_dict) response.data = [token] @@ -97,26 +90,94 @@ def tokens_create_post(project_id, scope=None): # noqa: E501 return cors_500(details=str(ex)) -def tokens_refresh_post(body: Request, project_id=None, scope=None): # noqa: E501 - """Refresh FABRIC OAuth tokens for an user +@login_required +def tokens_delete_delete(claims: dict = None): # noqa: E501 + """Delete all tokens for a user + + Request to delete all tokens for a user # noqa: E501 + @param claims + + :rtype: Status200OkNoContent + """ + received_counter.labels(HTTP_METHOD_DELETE, TOKENS_DELETE_URL).inc() + try: + credmgr = OAuthCredMgr() + credmgr.delete_tokens(user_email=claims.get(OAuthCredMgr.EMAIL)) + response_data = Status200OkNoContentData() + response_data.details = f"All token for user: {claims.get(OAuthCredMgr.EMAIL)} have been successfully deleted" + response = Status200OkNoContent() + response.data = [response_data] + response.size = len(response.data) + response.status = 200 + response.type = 'no_content' + LOG.debug(response) + success_counter.labels(HTTP_METHOD_DELETE, TOKENS_DELETE_URL).inc() + return cors_200(response_body=response) + except Exception as ex: + LOG.exception(ex) + failure_counter.labels(HTTP_METHOD_DELETE, TOKENS_DELETE_URL).inc() + return cors_500(details=str(ex)) + + +@login_required +def tokens_delete_token_hash_delete(token_hash: str, claims: dict = None): # noqa: E501 + """Delete a token for an user + + Request to delete a token for an user # noqa: E501 + + :param token_hash: Token identified by SHA256 Hash + :type token_hash: str + :param claims: + :type claims: dict + + :rtype: Status200OkNoContent + """ + received_counter.labels(HTTP_METHOD_DELETE, TOKENS_DELETE_TOKEN_HASH_URL).inc() + try: + credmgr = OAuthCredMgr() + credmgr.delete_tokens(token_hash=token_hash, user_email=claims.get(OAuthCredMgr.EMAIL)) + response_data = Status200OkNoContentData() + response_data.details = f"Token {token_hash} for user: {claims.get(OAuthCredMgr.EMAIL)} " \ + f"has been successfully deleted" + response = Status200OkNoContent() + response.data = [response_data] + response.size = len(response.data) + response.status = 200 + response.type = 'no_content' + LOG.debug(response) + success_counter.labels(HTTP_METHOD_DELETE, TOKENS_DELETE_TOKEN_HASH_URL).inc() + return cors_200(response_body=response) + except Exception as ex: + LOG.exception(ex) + failure_counter.labels(HTTP_METHOD_DELETE, TOKENS_DELETE_TOKEN_HASH_URL).inc() + return cors_500(details=str(ex)) + + +def tokens_refresh_post(body: Request, project_id=None, project_name=None, scope=None): # noqa: E501 + """Refresh tokens for an user Request to refresh OAuth tokens for an user # noqa: E501 :param body: :type body: dict | bytes - :param project_id: Project Id + :param project_id: Project identified by universally unique identifier :type project_id: str + :param project_name: Project identified by name + :type project_name: str :param scope: Scope for which token is requested :type scope: str - :rtype: Success + :rtype: Tokens """ received_counter.labels(HTTP_METHOD_POST, TOKENS_REFRESH_URL).inc() try: - ci_logon_id_token, refresh_token, cookie = authorize(connexion.request) - credmgr = OAuthCredmgr() - token_dict = credmgr.refresh_token(refresh_token=body.refresh_token, project=project_id, scope=scope, - cookie=cookie) + credmgr = OAuthCredMgr() + remote_addr = connexion.request.remote_addr + if connexion.request.headers.get('X-Real-IP') is not None: + remote_addr = connexion.request.headers.get('X-Real-IP') + token_dict = credmgr.refresh_token(refresh_token=body.refresh_token, project_id=project_id, + project_name=project_name, scope=scope, + remote_addr=remote_addr) response = Tokens() token = Token().from_dict(token_dict) response.data = [token] @@ -127,33 +188,34 @@ def tokens_refresh_post(body: Request, project_id=None, scope=None): # noqa: E5 return cors_200(response_body=response) except CustomOAuth2Error as ex: LOG.exception(ex) - LOG.error(f"Error: {ex.error} Type: {type(ex.error)}") + LOG.exception(ex.error) + LOG.exception(ex.description) failure_counter.labels(HTTP_METHOD_POST, TOKENS_REFRESH_URL).inc() - return cors_500(details=str(ex.error)) + return cors_500(details=str(ex.description)) except Exception as ex: LOG.exception(ex) failure_counter.labels(HTTP_METHOD_POST, TOKENS_REFRESH_URL).inc() return cors_500(details=str(ex)) -def tokens_revoke_post(body): # noqa: E501 +@login_or_token_required +def tokens_revoke_post(body: Request, claims: dict = None): # noqa: E501 """Revoke a refresh token for an user Request to revoke a refresh token for an user # noqa: E501 :param body: :type body: dict | bytes + :param claims + :type claims: dict :rtype: Success """ received_counter.labels(HTTP_METHOD_POST, TOKENS_REVOKE_URL).inc() - if connexion.request.is_json: - body = Request.from_dict(connexion.request.get_json()) # noqa: E501 try: - credmgr = OAuthCredmgr() + credmgr = OAuthCredMgr() credmgr.revoke_token(refresh_token=body.refresh_token) success_counter.labels(HTTP_METHOD_POST, TOKENS_REVOKE_URL).inc() - response = Status200OkNoContent() response_data = Status200OkNoContentData() response_data.details = f"Token '{body.refresh_token}' has been successfully revoked" response = Status200OkNoContent() @@ -167,3 +229,148 @@ def tokens_revoke_post(body): # noqa: E501 failure_counter.labels(HTTP_METHOD_POST, TOKENS_REVOKE_URL).inc() return cors_500(details=str(ex)) + +@login_or_token_required +def tokens_revokes_post(body: TokenPost, claims: dict = None): # noqa: E501 + """Revoke a refresh token for an user + + Request to revoke a refresh token for an user # noqa: E501 + + :param body: + :type body: dict | bytes + :param claims + :type claims: dict + + :rtype: Success + """ + received_counter.labels(HTTP_METHOD_POST, TOKENS_REVOKES_URL).inc() + try: + credmgr = OAuthCredMgr() + if body.type == "identity": + credmgr.revoke_identity_token(token_hash=body.token, user_email=claims.get(OAuthCredMgr.EMAIL), + cookie=claims.get(OAuthCredMgr.COOKIE)) + else: + credmgr.revoke_token(refresh_token=body.token) + success_counter.labels(HTTP_METHOD_POST, TOKENS_REVOKES_URL).inc() + response_data = Status200OkNoContentData() + response_data.details = f"Token of type '{body.type}' has been successfully revoked" + response = Status200OkNoContent() + response.data = [response_data] + response.size = len(response.data) + response.status = 200 + response.type = 'no_content' + return cors_200(response_body=response) + except Exception as ex: + LOG.exception(ex) + failure_counter.labels(HTTP_METHOD_POST, TOKENS_REVOKES_URL).inc() + return cors_500(details=str(ex)) + + +@login_or_token_required +def tokens_get(token_hash=None, project_id=None, expires=None, states=None, limit=None, offset=None, + claims: dict = None): # noqa: E501 + """Get tokens + + :param token_hash: Token identified by SHA256 hash + :type token_hash: str + :param project_id: Project identified by universally unique identifier + :type project_id: str + :param expires: Search for tokens with expiry time lesser than the specified expiration time + :type expires: str + :param states: Search for Tokens in the specified states + :type states: List[str] + :param limit: maximum number of results to return per page (1 or more) + :type limit: int + :param offset: number of items to skip before starting to collect the result set + :type offset: int + :param claims + :type claims: dict + + :rtype: Tokens + """ + received_counter.labels(HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL).inc() + + if expires is not None: + try: + expires = datetime.strptime(expires, OAuthCredMgr.TIME_FORMAT) + except Exception: + return cors_400(f"Expiry time is not in format {OAuthCredMgr.TIME_FORMAT}") + + try: + credmgr = OAuthCredMgr() + token_list = credmgr.get_tokens(token_hash=token_hash, project_id=project_id, user_email=claims.get(OAuthCredMgr.EMAIL), + expires=expires, states=states, limit=limit, offset=offset) + success_counter.labels(HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL).inc() + response = Tokens() + response.data = [] + for t in token_list: + token = Token().from_dict(t) + response.data.append(token) + response.size = len(response.data) + response.type = "token" + LOG.debug(response) + return cors_200(response_body=response) + except Exception as ex: + LOG.exception(ex) + failure_counter.labels(HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL).inc() + return cors_500(details=str(ex)) + + +def tokens_revoke_list_get(project_id: str): # noqa: E501 + """Get token revoke list i.e. list of revoked identity token hashes + + Get token revoke list i.e. list of revoked identity token hashes for a user in a project # noqa: E501 + + :param project_id: Project identified by universally unique identifier + :type project_id: str + + :rtype: RevokeList + """ + received_counter.labels(HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL).inc() + try: + credmgr = OAuthCredMgr() + token_list = credmgr.get_token_revoke_list(project_id=project_id) + success_counter.labels(HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL).inc() + response = RevokeList() + response.data = token_list + response.size = len(response.data) + response.type = "revoked token hashes" + return cors_200(response_body=response) + except Exception as ex: + LOG.exception(ex) + failure_counter.labels(HTTP_METHOD_GET, TOKENS_REVOKE_LIST_URL).inc() + return cors_500(details=str(ex)) + + +def tokens_validate_post(body: TokenPost): # noqa: E501 + """Validate an identity token issued by Credential Manager + + Validate an identity token issued by Credential Manager # noqa: E501 + + :param body: + :type body: dict | bytes + + :rtype: Status200OkNoContent + """ + received_counter.labels(HTTP_METHOD_POST, TOKENS_VALIDATE_URL).inc() + try: + if body.type == "identity": + credmgr = OAuthCredMgr() + state, claims = credmgr.validate_token(token=body.token) + else: + raise Exception(f"Invalid token type: {body.type}") + + success_counter.labels(HTTP_METHOD_POST, TOKENS_VALIDATE_URL).inc() + response_data = Status200OkNoContentData() + response_data.details = f"Token is {state}!" + response = DecodedToken() + response.data = [response_data] + response.size = len(response.data) + response.status = 200 + response.type = 'no_content' + response.token = claims + return cors_200(response_body=response) + except Exception as ex: + LOG.exception(ex) + failure_counter.labels(HTTP_METHOD_POST, TOKENS_VALIDATE_URL).inc() + return cors_500(details=str(ex)) \ No newline at end of file diff --git a/fabric_cm/credmgr/swagger_server/swagger/swagger.yaml b/fabric_cm/credmgr/swagger_server/swagger/swagger.yaml index 9f994dc..4e27500 100644 --- a/fabric_cm/credmgr/swagger_server/swagger/swagger.yaml +++ b/fabric_cm/credmgr/swagger_server/swagger/swagger.yaml @@ -78,6 +78,14 @@ paths: explode: true schema: type: string + - name: project_name + in: query + description: Project identified by name + required: false + style: form + explode: true + schema: + type: string - name: scope in: query description: Scope for which token is requested @@ -87,6 +95,27 @@ paths: schema: type: string default: all + - name: lifetime + in: query + description: Lifetime of the token requested in hours + required: false + style: form + explode: true + schema: + maximum: 1512 + type: integer + default: 4 + - name: comment + in: query + description: Comment + required: false + style: form + explode: true + schema: + maxLength: 100 + minLength: 10 + type: string + default: Create Token via GUI responses: "200": description: OK @@ -142,6 +171,14 @@ paths: explode: true schema: type: string + - name: project_name + in: query + description: Project identified by name + required: false + style: form + explode: true + schema: + type: string - name: scope in: query description: Scope for which token is requested @@ -191,13 +228,61 @@ paths: schema: $ref: '#/components/schemas/status_500_internal_server_error' x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller + /tokens/revokes: + post: + tags: + - tokens + summary: Revoke a token + description: | + Request to revoke a token + operationId: tokens_revokes_post + requestBody: + $ref: '#/components/requestBodies/payload_token' + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/status_200_ok_no_content' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/status_400_bad_request' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/status_401_unauthorized' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/status_403_forbidden' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/status_404_not_found' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/status_500_internal_server_error' + x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller /tokens/revoke: post: tags: - tokens - summary: Revoke a refresh token for an user + summary: Revoke a token for an user description: | - Request to revoke a refresh token for an user + Request to revoke a token for an user operationId: tokens_revoke_post requestBody: $ref: '#/components/requestBodies/payload_token_post' @@ -239,8 +324,338 @@ paths: schema: $ref: '#/components/schemas/status_500_internal_server_error' x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller + /tokens/delete/{token_hash}: + delete: + tags: + - tokens + summary: Delete a token for an user + description: | + Request to delete a token for an user + operationId: tokens_delete_token_hash_delete + parameters: + - name: token_hash + in: path + description: Token identified by SHA256 Hash + required: true + style: simple + explode: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/status_200_ok_no_content' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/status_400_bad_request' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/status_401_unauthorized' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/status_403_forbidden' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/status_404_not_found' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/status_500_internal_server_error' + x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller + /tokens/delete: + delete: + tags: + - tokens + summary: Delete all tokens for a user + description: | + Request to delete all tokens for a user + operationId: tokens_delete_delete + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/status_200_ok_no_content' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/status_400_bad_request' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/status_401_unauthorized' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/status_403_forbidden' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/status_404_not_found' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/status_500_internal_server_error' + x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller + /tokens/validate: + post: + tags: + - tokens + summary: Validate an identity token issued by Credential Manager + description: | + Validate an identity token issued by Credential Manager + operationId: tokens_validate_post + requestBody: + $ref: '#/components/requestBodies/payload_token' + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/decoded_token' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/status_400_bad_request' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/status_401_unauthorized' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/status_403_forbidden' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/status_404_not_found' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/status_500_internal_server_error' + x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller + /tokens/revoke_list: + get: + tags: + - tokens + summary: Get token revoke list i.e. list of revoked identity token hashes + description: | + Get token revoke list i.e. list of revoked identity token hashes for a user in a project + operationId: tokens_revoke_list_get + parameters: + - name: project_id + in: query + description: Project identified by universally unique identifier + required: false + style: form + explode: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/revoke_list' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/status_400_bad_request' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/status_401_unauthorized' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/status_403_forbidden' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/status_404_not_found' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/status_500_internal_server_error' + x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller + /tokens: + get: + tags: + - tokens + summary: Get tokens + description: | + Get tokens for a user in a project + operationId: tokens_get + parameters: + - name: token_hash + in: query + description: Token identified by SHA256 hash + required: false + style: form + explode: true + schema: + type: string + - name: project_id + in: query + description: Project identified by universally unique identifier + required: false + style: form + explode: true + schema: + type: string + - name: expires + in: query + description: Search for tokens with expiry time lesser than the specified + expiration time + required: false + style: form + explode: true + schema: + type: string + - name: states + in: query + description: Search for Tokens in the specified states + required: false + style: form + explode: true + schema: + type: array + items: + type: string + enum: + - Nascent + - Valid + - Refreshed + - Revoked + - Expired + - name: limit + in: query + description: maximum number of results to return per page (1 or more) + required: false + style: form + explode: true + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + default: 5 + - name: offset + in: query + description: number of items to skip before starting to collect the result + set + required: false + style: form + explode: true + schema: + minimum: 0 + type: integer + format: int32 + default: 0 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/tokens' + "400": + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/status_400_bad_request' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/status_401_unauthorized' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/status_403_forbidden' + "404": + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/status_404_not_found' + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/status_500_internal_server_error' + x-openapi-router-controller: fabric_cm.credmgr.swagger_server.controllers.tokens_controller components: schemas: + status_200_ok_paginated: + type: object + properties: + limit: + type: integer + offset: + type: integer + size: + type: integer + status: + type: integer + default: 200 + type: + type: string status_200_ok_single: type: object properties: @@ -408,10 +823,28 @@ components: default: Internal Server Error details: type: string - tokens: + revoke_list: type: object allOf: - $ref: '#/components/schemas/status_200_ok_single' + - type: object + properties: + data: + type: array + items: + type: string + decoded_token: + type: object + allOf: + - $ref: '#/components/schemas/status_200_ok_no_content' + - type: object + properties: + token: + type: object + tokens: + type: object + allOf: + - $ref: '#/components/schemas/status_200_ok_paginated' - type: object properties: data: @@ -421,18 +854,42 @@ components: token: required: - created_at - - id_token - - refresh_token + - created_from + - expires_at + - state + - token_hash type: object properties: + token_hash: + type: string + description: Identity Token SHA256 Hash + created_at: + type: string + description: Token creation time + expires_at: + type: string + description: Token expiry time + state: + type: string + description: Token state + enum: + - Nascent + - Valid + - Refreshed + - Revoked + - Expired + created_from: + type: string + description: Remote IP from where the token create request was received + comment: + type: string + description: Comment provided at creation id_token: type: string description: Identity Token refresh_token: type: string description: Refresh Token - created_at: - type: string request: required: - refresh_token @@ -441,6 +898,21 @@ components: refresh_token: type: string description: Refresh Token + token_post: + required: + - token + - type + type: object + properties: + type: + type: string + description: Token Type + enum: + - identity + - refresh + token: + type: string + description: Refresh Token or Token Hash version: type: object allOf: @@ -476,13 +948,13 @@ components: use: use kid: kid alg: alg - "n": "n" + n: "n" - kty: kty e: e use: use kid: kid alg: alg - "n": "n" + n: "n" jwks_keys: type: object properties: @@ -492,7 +964,7 @@ components: e: type: string description: Exponent Parameter - "n": + n: type: string description: Modulus Parameter use: @@ -510,7 +982,7 @@ components: use: use kid: kid alg: alg - "n": "n" + n: "n" requestBodies: payload_token_post: content: @@ -518,4 +990,10 @@ components: schema: $ref: '#/components/schemas/request' required: true + payload_token: + content: + application/json: + schema: + $ref: '#/components/schemas/token_post' + required: true diff --git a/fabric_cm/credmgr/swagger_server/test/test_default_controller.py b/fabric_cm/credmgr/swagger_server/test/test_default_controller.py index befe100..ce6420b 100644 --- a/fabric_cm/credmgr/swagger_server/test/test_default_controller.py +++ b/fabric_cm/credmgr/swagger_server/test/test_default_controller.py @@ -19,7 +19,7 @@ def test_certs_get(self): Return Public Keys to verify signature of the tokens """ response = self.client.open( - '//certs', + '/credmgr//certs', method='GET') self.assert200(response, 'Response body is : ' + response.data.decode('utf-8')) diff --git a/fabric_cm/credmgr/swagger_server/test/test_tokens_controller.py b/fabric_cm/credmgr/swagger_server/test/test_tokens_controller.py index 0231ab0..0041416 100644 --- a/fabric_cm/credmgr/swagger_server/test/test_tokens_controller.py +++ b/fabric_cm/credmgr/swagger_server/test/test_tokens_controller.py @@ -5,13 +5,16 @@ from flask import json from six import BytesIO +from fabric_cm.credmgr.swagger_server.models.decoded_token import DecodedToken # noqa: E501 from fabric_cm.credmgr.swagger_server.models.request import Request # noqa: E501 +from fabric_cm.credmgr.swagger_server.models.revoke_list import RevokeList # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status200_ok_no_content import Status200OkNoContent # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status400_bad_request import Status400BadRequest # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status401_unauthorized import Status401Unauthorized # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status403_forbidden import Status403Forbidden # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status404_not_found import Status404NotFound # noqa: E501 from fabric_cm.credmgr.swagger_server.models.status500_internal_server_error import Status500InternalServerError # noqa: E501 +from fabric_cm.credmgr.swagger_server.models.token_post import TokenPost # noqa: E501 from fabric_cm.credmgr.swagger_server.models.tokens import Tokens # noqa: E501 from fabric_cm.credmgr.swagger_server.test import BaseTestCase @@ -25,14 +28,57 @@ def test_tokens_create_post(self): Generate tokens for an user """ query_string = [('project_id', 'project_id_example'), - ('scope', 'all')] + ('project_name', 'project_name_example'), + ('scope', 'all'), + ('lifetime', 1512), + ('comment', 'Create Token via GUI')] response = self.client.open( - '//tokens/create', + '/credmgr//tokens/create', method='POST', query_string=query_string) self.assert200(response, 'Response body is : ' + response.data.decode('utf-8')) + def test_tokens_delete_delete(self): + """Test case for tokens_delete_delete + + Delete all tokens for a user + """ + response = self.client.open( + '/credmgr//tokens/delete', + method='DELETE') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_tokens_delete_token_hash_delete(self): + """Test case for tokens_delete_token_hash_delete + + Delete a token for an user + """ + response = self.client.open( + '/credmgr//tokens/delete/{token_hash}'.format(token_hash='token_hash_example'), + method='DELETE') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_tokens_get(self): + """Test case for tokens_get + + Get tokens + """ + query_string = [('token_hash', 'token_hash_example'), + ('project_id', 'project_id_example'), + ('expires', 'expires_example'), + ('states', 'states_example'), + ('limit', 200), + ('offset', 1)] + response = self.client.open( + '/credmgr//tokens', + method='GET', + query_string=query_string) + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + def test_tokens_refresh_post(self): """Test case for tokens_refresh_post @@ -40,9 +86,10 @@ def test_tokens_refresh_post(self): """ body = Request() query_string = [('project_id', 'project_id_example'), + ('project_name', 'project_name_example'), ('scope', 'all')] response = self.client.open( - '//tokens/refresh', + '/credmgr//tokens/refresh', method='POST', data=json.dumps(body), content_type='application/json', @@ -50,14 +97,55 @@ def test_tokens_refresh_post(self): self.assert200(response, 'Response body is : ' + response.data.decode('utf-8')) + def test_tokens_revoke_list_get(self): + """Test case for tokens_revoke_list_get + + Get token revoke list i.e. list of revoked identity token hashes + """ + query_string = [('project_id', 'project_id_example')] + response = self.client.open( + '/credmgr//tokens/revoke_list', + method='GET', + query_string=query_string) + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + def test_tokens_revoke_post(self): """Test case for tokens_revoke_post - Revoke a refresh token for an user + Revoke a token for an user """ body = Request() response = self.client.open( - '//tokens/revoke', + '/credmgr//tokens/revoke', + method='POST', + data=json.dumps(body), + content_type='application/json') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_tokens_revokes_post(self): + """Test case for tokens_revokes_post + + Revoke a token + """ + body = TokenPost() + response = self.client.open( + '/credmgr//tokens/revokes', + method='POST', + data=json.dumps(body), + content_type='application/json') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_tokens_validate_post(self): + """Test case for tokens_validate_post + + Validate an identity token issued by Credential Manager + """ + body = TokenPost() + response = self.client.open( + '/credmgr//tokens/validate', method='POST', data=json.dumps(body), content_type='application/json') diff --git a/fabric_cm/credmgr/swagger_server/test/test_version_controller.py b/fabric_cm/credmgr/swagger_server/test/test_version_controller.py index 1cf512a..1eb7bdc 100644 --- a/fabric_cm/credmgr/swagger_server/test/test_version_controller.py +++ b/fabric_cm/credmgr/swagger_server/test/test_version_controller.py @@ -19,7 +19,7 @@ def test_version_get(self): Version """ response = self.client.open( - '//version', + '/credmgr//version', method='GET') self.assert200(response, 'Response body is : ' + response.data.decode('utf-8')) diff --git a/fabric_cm/credmgr/token/fabric_token_encoder.py b/fabric_cm/credmgr/token/token_encoder.py similarity index 67% rename from fabric_cm/credmgr/token/fabric_token_encoder.py rename to fabric_cm/credmgr/token/token_encoder.py index 1fb9b79..91767e4 100644 --- a/fabric_cm/credmgr/token/fabric_token_encoder.py +++ b/fabric_cm/credmgr/token/token_encoder.py @@ -25,8 +25,8 @@ from datetime import datetime from dateutil import tz from fss_utils.jwt_manager import JWTManager, ValidateCode -from fss_utils.vouch_encoder import VouchEncoder, CustomClaimsType, PTokens +from fabric_cm.credmgr.common.utils import Utils from fabric_cm.credmgr.config import CONFIG_OBJ from fabric_cm.credmgr.external_apis.ldap import CmLdapMgrSingleton from fabric_cm.credmgr.logging import LOG @@ -34,30 +34,40 @@ from fabric_cm.credmgr.external_apis.core_api import CoreApi -class FabricTokenEncoder: +class TokenEncoder: """ Implements class to transform CILogon ID token to Fabric Id Token by adding the project, scope and membership information to the token and signing with Fabric Certificate """ - def __init__(self, id_token, idp_claims: dict, project: str, scope: str = "all", cookie: str = None): + PROJECTS = "projects" + ROLES = "roles" + UUID = "uuid" + SCOPE = "scope" + TAGS = "tags" + EMAIL = "email" + SUB = "sub" + + def __init__(self, id_token, idp_claims: dict, project_id: str = None, project_name: str = None, + scope: str = "all", cookie: str = None): """ Constructor :param id_token: CI Logon Identity Token :param idp_claims: CI Logon Identity Claims - :param project: Project for which token is requested + :param project_id: Project Id of the project for which token is requested + :param project_name: Project Name of the project for which token is requested :param scope: Scope for which token is requested :param cookie: Vouch Proxy Cookie :raises Exception in case of error """ - if id_token is None or project is None or scope is None: + if id_token is None or (project_id is None and project_name is None) or scope is None: raise TokenError("Missing required parameters id_token or project or scope") - LOG.debug("id_token %s", id_token) self.id_token = id_token self.claims = idp_claims - self.project = project + self.project_id = project_id + self.project_name = project_name self.scope = scope self.cookie = cookie self.encoded = False @@ -78,6 +88,11 @@ def encode(self, private_key: str, validity_in_seconds: int, kid: str, pass_phra self._add_fabric_claims() + if not Utils.is_short_lived(lifetime_in_hours=int(validity_in_seconds/3600)) and \ + not self._validate_lifetime(validity=validity_in_seconds, project_id=self.project_id, + roles=self.claims.get(self.ROLES)): + raise TokenError(f"User {self.claims[self.EMAIL]} is not authorized to create long lived tokens!") + code, token_or_exception = JWTManager.encode_and_sign_with_private_key(validity=validity_in_seconds, claims=self.claims, private_key_file_name=private_key, @@ -91,74 +106,57 @@ def encode(self, private_key: str, validity_in_seconds: int, kid: str, pass_phra self.encoded = True return self.token - def _get_vouch_cookie(self) -> str: - vouch_cookie_enabled = CONFIG_OBJ.is_vouch_cookie_enabled() - if not vouch_cookie_enabled or self.cookie is not None: - return self.cookie - - vouch_secret = CONFIG_OBJ.get_vouch_secret() - vouch_compression = CONFIG_OBJ.is_vouch_cookie_compressed() - vouch_claims = CONFIG_OBJ.get_vouch_custom_claims() - vouch_cookie_lifetime = CONFIG_OBJ.get_vouch_cookie_lifetime() - vouch_helper = VouchEncoder(secret=vouch_secret, compression=vouch_compression) - - custom_claims = [] - for c in vouch_claims: - c_type = c.strip().upper() - - if c_type == CustomClaimsType.OPENID.name: - custom_claims.append(CustomClaimsType.OPENID) - - if c_type == CustomClaimsType.EMAIL.name: - custom_claims.append(CustomClaimsType.EMAIL) - - if c_type == CustomClaimsType.PROFILE.name: - custom_claims.append(CustomClaimsType.PROFILE) - - if c_type == CustomClaimsType.CILOGON_USER_INFO.name: - custom_claims.append(CustomClaimsType.CILOGON_USER_INFO) - - p_tokens = PTokens(id_token=self.id_token, idp_claims=self.claims) + def _validate_lifetime(self, *, validity: int, roles: dict, project_id: str): + """ + Set the claims for the Token by adding membership, project and scope + """ + if validity == CONFIG_OBJ.get_token_life_time(): + return True - code, cookie_or_exception = vouch_helper.encode(custom_claims_type=custom_claims, p_tokens=p_tokens, - validity_in_seconds=vouch_cookie_lifetime) + llt_role = f"{project_id}-{CONFIG_OBJ.get_llt_role_suffix()}" - if code != ValidateCode.VALID: - LOG.error(f"Failed to encode the Vouch Cookie: {cookie_or_exception}") - raise cookie_or_exception + # User doesn't have the role to create Long lived tokens + for role in roles: + if llt_role in role.values(): + return True - return cookie_or_exception + return False def _add_fabric_claims(self): """ Set the claims for the Token by adding membership, project and scope """ - url = CONFIG_OBJ.get_core_api_url() - if CONFIG_OBJ.is_core_api_enabled(): - core_api = CoreApi(api_server=url, cookie=self._get_vouch_cookie(), + cookie = Utils.get_vouch_cookie(cookie=self.cookie, id_token=self.id_token, + claims=self.claims) + + if self.project_id is None: + self.project_id = Utils.get_project_id(project_name=self.project_name, cookie=cookie) + + core_api = CoreApi(api_server=CONFIG_OBJ.get_core_api_url(), + cookie=cookie, cookie_name=CONFIG_OBJ.get_vouch_cookie_name(), cookie_domain=CONFIG_OBJ.get_vouch_cookie_domain_name()) - email, uuid, roles, projects = core_api.get_user_and_project_info(project_id=self.project) + email, uuid, roles, projects = core_api.get_user_and_project_info(project_id=self.project_id) else: uuid = None - email = self.claims.get("email") - sub = self.claims.get("sub") + email = self.claims.get(self.EMAIL) + sub = self.claims.get(self.SUB) email, roles, tags = CmLdapMgrSingleton.get().get_user_and_project_info(eppn=None, email=email, sub=sub, - project_id=self.project) + project_id=self.project_id) projects = [{ - "uuid": self.project, - "tags": tags + self.UUID: self.project_id, + self.TAGS: tags }] LOG.debug(f"UUID: {uuid} Roles: {roles} Projects: {projects}") - self.claims["projects"] = projects - self.claims["roles"] = roles - self.claims["scope"] = self.scope + self.claims[self.PROJECTS] = projects + self.claims[self.ROLES] = roles + self.claims[self.SCOPE] = self.scope if uuid is not None: - self.claims["uuid"] = uuid - if self.claims.get("email") is None: - self.claims["email"] = email + self.claims[self.UUID] = uuid + if self.claims.get(self.EMAIL) is None: + self.claims[self.EMAIL] = email LOG.debug("Claims %s", self.claims) self.unset = False @@ -184,4 +182,4 @@ def __str__(self) -> str: fstring += f"\n\tExpires on: " \ f"{self.get_local_from_utc(self.claims['exp']).strftime('%Y-%m-%d %H:%M:%S')}" - return fstring + return fstring \ No newline at end of file diff --git a/fabric_cm/db/__init__.py b/fabric_cm/db/__init__.py new file mode 100644 index 0000000..86436c7 --- /dev/null +++ b/fabric_cm/db/__init__.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2020 FABRIC Testbed +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# +# Author: Komal Thareja (kthare10@renci.org) + +from sqlalchemy import TIMESTAMP +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import Column, String, Integer, Sequence + +Base = declarative_base() + + +class Tokens(Base): + """ + Represents Tokens Database Table + """ + __tablename__ = 'Tokens' + token_id = Column(Integer, Sequence('token_id', start=1, increment=1), autoincrement=True, primary_key=True) + user_id = Column(String, nullable=False, index=True) + user_email = Column(String, nullable=False, index=True) + project_id = Column(String, nullable=False, index=True) + comment = Column(String, nullable=False) + state = Column(Integer, nullable=False, index=True) + token_hash = Column(String, nullable=False, index=True) + created_from = Column(String, nullable=False) + created_at = Column(TIMESTAMP(timezone=True), nullable=True) + expires_at = Column(TIMESTAMP(timezone=True), nullable=True) diff --git a/fabric_cm/db/db_api.py b/fabric_cm/db/db_api.py new file mode 100644 index 0000000..5c8d961 --- /dev/null +++ b/fabric_cm/db/db_api.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2020 FABRIC Testbed +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# +# Author: Komal Thareja (kthare10@renci.org) +import threading +from datetime import datetime +from typing import List + +from fabric_cm.db import Base, Tokens +from sqlalchemy import create_engine, desc +from sqlalchemy.orm import scoped_session, sessionmaker + + +class DbApi: + """ + Implements interface to Postgres database + """ + + def __init__(self, *, user: str, password: str, database: str, db_host: str, logger): + # Connecting to PostgreSQL server at localhost using psycopg2 DBAPI + self.db_engine = create_engine("postgresql+psycopg2://{}:{}@{}/{}".format(user, password, db_host, database)) + self.logger = logger + self.session_factory = sessionmaker(bind=self.db_engine) + self.sessions = {} + + def get_session(self): + thread_id = threading.get_ident() + session = None + if thread_id in self.sessions: + session = self.sessions.get(thread_id) + else: + session = scoped_session(self.session_factory) + self.sessions[thread_id] = session + return session + + def create_db(self): + """ + Create the database + """ + Base.metadata.create_all(self.db_engine) + + def set_logger(self, logger): + """ + Set the logger + """ + self.logger = logger + + def reset_db(self): + """ + Reset the database + """ + session = self.get_session() + try: + session.query(Tokens).delete() + session.commit() + except Exception as e: + session.rollback() + self.logger.error(f"Exception occurred: {e}", stack_info=True) + raise e + + def add_token(self, *, user_id: str, user_email: str, project_id: str, created_from: str, state: int, + token_hash: str, created_at: datetime, expires_at: datetime, comment: str): + """ + Add a token + @param user_id User ID + @param user_email User Email + @param project_id Project ID + @param created_from Remote IP Address + @param state Token State + @param token_hash Token hash + @param created_at creation time of the token + @param expires_at expiration time of the token + @param comment comment describing when token was created + """ + session = self.get_session() + try: + # Save the token in the database + token_obj = Tokens(user_id=user_id, user_email=user_email, project_id=project_id, + created_from=created_from, state=state, token_hash=token_hash, + expires_at=expires_at, created_at=created_at, comment=comment) + session.add(token_obj) + session.commit() + except Exception as e: + session.rollback() + self.logger.error(f"Exception occurred: {e}", stack_info=True) + raise e + + def update_token(self, *, token_hash: str, state: int): + """ + Update token + @param token_hash token_hash + @param state Token State + """ + session = self.get_session() + try: + token = session.query(Tokens).filter_by(token_hash=token_hash).one_or_none() + if token is not None: + token.state = state + else: + raise Exception(f"Token #{token_hash} not found!") + session.commit() + except Exception as e: + session.rollback() + self.logger.error(f"Exception occurred: {e}", stack_info=True) + raise e + + def remove_token(self, *, token_hash: str): + """ + Remove a token + @param token_hash token hash + """ + session = self.get_session() + try: + # Delete the actor in the database + session.query(Tokens).filter_by(token_hash=token_hash).delete() + session.commit() + except Exception as e: + session.rollback() + self.logger.error(f"Exception occurred: {e}", stack_info=True) + raise e + + def get_tokens(self, *, user_id: str = None, user_email: str = None, project_id: str = None, + token_hash: str = None, expires: datetime = None, states: List[int] = None, + offset: int = 0, limit: int = 5) -> list: + """ + Get tokens + @param user_id User Id + @param user_email User's email + @param project_id Project Id + @param token_hash Token hash + @param expires Expiration Time + @param states list of states + @param offset offset + @param limit limit + @return list of tokens + """ + result = [] + session = self.get_session() + try: + filter_dict = self.__create_token_filter(user_id=user_id, project_id=project_id, user_email=user_email, + token_hash=token_hash) + + rows = session.query(Tokens).filter_by(**filter_dict) + + if expires is not None: + rows = rows.filter(Tokens.expires_at < expires) + + if states is not None: + rows = rows.filter(Tokens.state.in_(states)) + + rows = rows.order_by(desc(Tokens.expires_at)) + + if offset is not None and limit is not None: + rows = rows.offset(offset).limit(limit) + + for row in rows.all(): + result.append(self.__generate_dict_from_row(row=row)) + except Exception as e: + self.logger.error(f"Exception occurred: {e}", stack_info=True) + raise e + return result + + @staticmethod + def __create_token_filter(*, user_id: str, user_email: str, project_id: str, token_hash: str) -> dict: + + filter_dict = {} + if user_id is not None: + filter_dict['user_id'] = user_id + if user_email is not None: + filter_dict['user_email'] = str(user_email) + if project_id is not None: + filter_dict['project_id'] = str(project_id) + if token_hash is not None: + filter_dict['token_hash'] = token_hash + return filter_dict + + @staticmethod + def __generate_dict_from_row(row): + d = row.__dict__.copy() + for k in row.__dict__: + if d[k] is None: + d.pop(k) + return d diff --git a/pyproject.toml b/pyproject.toml index e21f3ff..81c9aeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,8 @@ requires-python = '>=3.9' dependencies = [ "requests", "requests_oauthlib", - "connexion", - "swagger-ui-bundle", + "connexion==2.14.2", + "swagger-ui-bundle==0.0.9", "python_dateutil", "setuptools", "ldap3", @@ -28,7 +28,20 @@ dependencies = [ "six", "cryptography", "authlib", - "fabric_fss_utils==1.5.0"] + "fabric_fss_utils==1.5.1", + "psycopg2-binary", + "sqlalchemy", + ] + +[project.optional-dependencies] +test = ["pytest", + "flask_testing", + "coverage>=4.0.3", + "nose>=1.3.7", + "pluggy>=0.3.1", + "py>=1.4.31", + "randomize>=0.13" + ] [project.urls] Home = "https://fabric-testbed.net/" diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 3bff640..0000000 --- a/test-requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -flask_testing -coverage>=4.0.3 -nose>=1.3.7 -pluggy>=0.3.1 -py>=1.4.31 -randomize>=0.13