From 29b99eab69da0d84884ed9fb54a4b4ad478f5d48 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 16 Aug 2023 14:24:41 +0800 Subject: [PATCH] feat(console): python guide --- .../components/ClientBasics/index.tsx | 2 - .../assets/docs/guides/web-python/README.mdx | 223 +++++++++++++++++- .../assets/docs/guides/web-python/index.ts | 7 +- .../components/GuideV2/index.module.scss | 4 +- .../Applications/components/GuideV2/index.tsx | 8 + 5 files changed, 238 insertions(+), 6 deletions(-) diff --git a/packages/console/src/assets/docs/guides/web-gpt-plugin/components/ClientBasics/index.tsx b/packages/console/src/assets/docs/guides/web-gpt-plugin/components/ClientBasics/index.tsx index 45b337219..ee0c93bce 100644 --- a/packages/console/src/assets/docs/guides/web-gpt-plugin/components/ClientBasics/index.tsx +++ b/packages/console/src/assets/docs/guides/web-gpt-plugin/components/ClientBasics/index.tsx @@ -1,5 +1,4 @@ import { useContext } from 'react'; -import { useTranslation } from 'react-i18next'; import CopyToClipboard from '@/ds-components/CopyToClipboard'; import FormField from '@/ds-components/FormField'; @@ -11,7 +10,6 @@ export default function ClientBasics() { const { app: { id, secret }, } = useContext(GuideContext); - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); return (
diff --git a/packages/console/src/assets/docs/guides/web-python/README.mdx b/packages/console/src/assets/docs/guides/web-python/README.mdx index ae759f749..46dbb2dd0 100644 --- a/packages/console/src/assets/docs/guides/web-python/README.mdx +++ b/packages/console/src/assets/docs/guides/web-python/README.mdx @@ -1 +1,222 @@ -## Replace this with actual guide \ No newline at end of file +import UriInputField from '@/mdx-components-v2/UriInputField'; +import Tabs from '@mdx/components/Tabs'; +import TabItem from '@mdx/components/TabItem'; +import InlineNotification from '@/ds-components/InlineNotification'; +import { buildIdGenerator } from '@logto/shared/universal'; +import Steps from '@/mdx-components-v2/Steps'; +import Step from '@/mdx-components-v2/Step'; + + + + + +This tutorial will show you how to integrate Logto into your Python web application. + +
    +
  • The example uses Flask, but the concepts are the same for other frameworks.
  • +
  • This tutorial assumes your website is hosted on {props.sampleUrls.origin}.
  • +
  • Logto SDK leverages coroutines, remember to use await when calling async functions.
  • +
+ +```bash +pip install logto # or `poetry add logto` or whatever you use +``` + +
+ + + +Insert the following code into your python file: + +
+  
+{`from logto import LogtoClient, LogtoConfig
+
+client = LogtoClient(
+    LogtoConfig(
+        endpoint= "${props.endpoint}",${props.alternativeEndpoint ? ' // or "${props.alternativeEndpoint}"' : ''}
+        appId="${props.app.id}",
+        appSecret="${props.app.secret}",
+    )
+)`}
+  
+
+ +Also replace the default memory storage with a persistent storage, for example: + +```python +from logto import LogtoClient, LogtoConfig, Storage +from flask import session + +class SessionStorage(Storage): + def get(self, key: str) -> str | None: + return session.get(key, None) + + def set(self, key: str, value: str | None) -> None: + session[key] = value + + def delete(self, key: str) -> None: + session.pop(key, None) + +client = LogtoClient( + LogtoConfig(...), + storage=SessionStorage(), +) +``` + +
+ + + +

+First, let’s enter your redirect URI. E.g. {props.sampleUrls.callback}. This is where Logto will redirect users after they sign in. +

+ + + +
+  
+{`@app.route("/sign-in")
+async def sign_in():
+    # Get the sign-in URL and redirect the user to it
+    return redirect(await client.signIn(
+        redirectUri="${props.redirectUris[0] || props.sampleUrls.callback}",
+    ))`}
+  
+
+ +If you want to show the sign-up page on the first screen, you can set `interactionMode` to `signUp`: + +
+  
+{`@app.route("/sign-in")
+async def sign_in():
+    # Get the sign-in URL and redirect the user to it
+    return redirect(await client.signIn(
+        redirectUri="${props.redirectUris[0] || props.sampleUrls.callback}",
+        interactionMode="signUp", # Show the sign-up page on the first screen
+    ))`}
+  
+
+ +Now, whenever your users visit `/sign-in`, it will start a new sign-in attempt and redirect the user to the Logto sign-in page. + +
+ + + +After the user signs in, Logto will redirect the user to the callback URL you set in the Logto Console. In this example, we use `/callback` as the callback URL: + +
+  
+{`@app.route("/callback")
+async def callback():
+    try:
+        await client.handleSignInCallback(request.url) # Handle a lot of stuff
+        return redirect("/") # Redirect the user to the home page after a successful sign-in
+    except Exception as e:
+        # Change this to your error handling logic
+        return "Error: " + str(e)
+`}
+  
+
+ +
+ + + +Here we implement a simple home page for demonstration: + +- If the user is not signed in, show a sign-in button; +- If the user is signed in, show some basic information about the user. + +```python +@app.route("/") +async def home(): + if client.isAuthenticated() is False: + return "Not authenticated Sign in" + + return ( + # Get local ID token claims + client.getIdTokenClaims().model_dump_json(exclude_unset=True) + + "
" + # Fetch user info from Logto userinfo endpoint + (await client.fetchUserInfo()).model_dump_json(exclude_unset=True) + + "
Sign out" + ) +``` + +Our data models are based on [pydantic](https://docs.pydantic.dev/), so you can use `model_dump_json` to dump the data model to JSON. + +Adding `exclude_unset=True` will exclude unset fields from the JSON output, which makes the output more precise. + +For example, if we didn't request the `email` scope when signing in, and the `email` field will be excluded from the JSON output. However, if we requested the `email` scope, but the user doesn't have an email address, the `email` field will be included in the JSON output with a `null` value. + +To learn more about scopes and claims, see [Scopes and claims](https://github.com/logto-io/python/blob/master/docs/tutorial.md#scopes-and-claims). + +
+ + + +To clean up the Python session and Logto session, a sign-out route can be implemented as follows: + +
+  
+{`@app.route("/sign-out")
+async def sign_out():
+    return redirect(
+        # Redirect the user to the home page after a successful sign-out
+        await client.signOut(postLogoutRedirectUri="${props.sampleUrls.origin}")
+    )
+`}
+  
+
+ +`postLogoutRedirectUri` is optional, and if not provided, the user will be redirected to a Logto default page after a successful sign-out (without redirecting back to your application). + +> The name `postLogoutRedirectUri` is from the [OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) specification. Although Logto uses "sign-out" instead of "logout", the concept is the same. + +
+ + + +Now, you can test your application: + +
    +
  1. Visit {props.sampleUrls.origin}, and you should see a "Not authenticated" message with a "Sign in" button.
  2. +
  3. Click the "Sign in" button, and you should be redirected to the Logto sign-in page.
  4. +
  5. After you sign in, you should be redirected back to {props.sampleUrls.origin}, and you should see your user info and a "Sign out" button.
  6. +
  7. Click the "Sign out" button, and you should be redirected back to {props.sampleUrls.origin}, and you should see a "Not authenticated" message with a "Sign in" button.
  8. +
+ +
+ + + +Now, you have a working sign-in flow, but your routes are still unprotected. Per the framework you use, you can create a decorator such as `@authenticated` to protect your routes. For example: + +```python +def authenticated(func): + @wraps(func) + async def wrapper(*args, **kwargs): + if client.isAuthenticated() is False: + return redirect("/sign-in") # Or directly call `client.signIn` + return await func(*args, **kwargs) + + return wrapper +``` + +Then, you can use the decorator to protect your routes: + +```python +@app.route("/protected") +@authenticated +async def protected(): + return "Protected page" +``` + +You can also create a middleware to achieve the same goal. + + + +
diff --git a/packages/console/src/assets/docs/guides/web-python/index.ts b/packages/console/src/assets/docs/guides/web-python/index.ts index 619b9bb94..750c665a2 100644 --- a/packages/console/src/assets/docs/guides/web-python/index.ts +++ b/packages/console/src/assets/docs/guides/web-python/index.ts @@ -4,9 +4,12 @@ import { type GuideMetadata } from '../types'; const metadata: Readonly = Object.freeze({ name: 'Python', - description: - 'Python is a programming language that lets you work quickly and integrate systems more effectively.', + description: 'Integrate Logto into your Python web app, such as Django and Flask.', target: ApplicationType.Traditional, + sample: { + repo: 'python', + path: 'samples', + }, }); export default metadata; diff --git a/packages/console/src/pages/Applications/components/GuideV2/index.module.scss b/packages/console/src/pages/Applications/components/GuideV2/index.module.scss index 394c3c879..a5461f8e3 100644 --- a/packages/console/src/pages/Applications/components/GuideV2/index.module.scss +++ b/packages/console/src/pages/Applications/components/GuideV2/index.module.scss @@ -19,7 +19,9 @@ margin: _.unit(4) 0; } - section ul > li { + section ul > li, + section ol > li { + font: var(--font-body-2); margin-block: _.unit(2); padding-inline-start: _.unit(1); } diff --git a/packages/console/src/pages/Applications/components/GuideV2/index.tsx b/packages/console/src/pages/Applications/components/GuideV2/index.tsx index 8a7c4d7cf..d6e7b6075 100644 --- a/packages/console/src/pages/Applications/components/GuideV2/index.tsx +++ b/packages/console/src/pages/Applications/components/GuideV2/index.tsx @@ -33,6 +33,10 @@ type GuideContextType = { redirectUris: string[]; postLogoutRedirectUris: string[]; isCompact: boolean; + sampleUrls: { + origin: string; + callback: string; + }; }; // eslint-disable-next-line no-restricted-syntax @@ -68,6 +72,10 @@ function GuideV2({ guideId, app, isCompact, onClose }: Props) { redirectUris: app.oidcClientMetadata.redirectUris, postLogoutRedirectUris: app.oidcClientMetadata.postLogoutRedirectUris, isCompact: Boolean(isCompact), + sampleUrls: { + origin: 'http://localhost:3001/', + callback: 'http://localhost:3001/callback', + }, }) satisfies GuideContextType, [guide, app, tenantEndpoint, isCustomDomainActive, isCompact] );