diff --git a/configuration.md b/configuration.md index 7eae6a5b..fc85c3f5 100644 --- a/configuration.md +++ b/configuration.md @@ -25,6 +25,8 @@ Here are the available configuration options and their default values: | `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to [`templates/`](https://sql-page.com/custom_components.sql), [`migrations/`](https://sql-page.com/your-first-sql-website/migrations.sql), and `on_connect.sql`. Obviously, this configuration parameter can be set only through environment variables, not through the `sqlpage.json` file itself in order to find the `sqlpage.json` file. Be careful not to use a path that is accessible from the public WEB_ROOT | | `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. | | `max_uploaded_file_size` | 5242880 | Maximum size of forms and uploaded files in bytes. Defaults to 5 MiB. | +| `oidc_protected_paths` | `["/"]` | A list of URL prefixes that should be protected by OIDC authentication. By default, all paths are protected (`["/"]`). If you want to make some pages public, you can restrict authentication to a sub-path, for instance `["/admin", "/users/settings"]`. | +| `oidc_public_paths` | `[]` | A list of URL prefixes that should be publicly available. By default, no paths are publicly accessible (`[]`). If you want to make some pages public, you can bypass authentication for a sub-path, for instance `["/public/", "/assets/"]`. Keep in mind that without the closing backslashes, that any directory or file starting with `public` or `assets` will be publicly available. This will also overwrite any protected path restriction. If you have a private path `/private` and you define the public path `/private/public/` everything in `/private/public/` will be publicly accessible, while everything else in private will still need authentication. | `oidc_issuer_url` | | The base URL of the [OpenID Connect provider](#openid-connect-oidc-authentication). Required for enabling Single Sign-On. | | `oidc_client_id` | sqlpage | The ID that identifies your SQLPage application to the OIDC provider. You get this when registering your app with the provider. | | `oidc_client_secret` | | The secret key for your SQLPage application. Keep this confidential as it allows your app to authenticate with the OIDC provider. | @@ -90,7 +92,7 @@ This allows you to keep the password separate from the connection string, which ### OpenID Connect (OIDC) Authentication -OpenID Connect (OIDC) is a secure way to let users log in to your SQLPage application using their existing accounts from popular services. When OIDC is configured, all access to your SQLPage application will require users to log in through the chosen provider. This enables Single Sign-On (SSO), allowing you to restrict access to your application without having to handle authentication yourself. +OpenID Connect (OIDC) is a secure way to let users log in to your SQLPage application using their existing accounts from popular services. When OIDC is configured, you can control which parts of your application require authentication using the `oidc_protected_paths` option. By default, all pages are protected. You can specify a list of URL prefixes to protect specific areas, allowing you to have a mix of public and private pages. To set up OIDC, you'll need to: 1. Register your application with an OIDC provider diff --git a/examples/official-site/sso/single_sign_on.md b/examples/official-site/sso/single_sign_on.md index b96c4c1f..d10e6b67 100644 --- a/examples/official-site/sso/single_sign_on.md +++ b/examples/official-site/sso/single_sign_on.md @@ -1,6 +1,5 @@ # Setting Up Single Sign-On in SQLPage - When you want to add user authentication to your SQLPage application, you have two main options: 1. The [authentication component](/component.sql?component=authentication): @@ -60,9 +59,7 @@ Create or edit `sqlpage/sqlpage.json` to add the following configuration keys: When you restart your SQLPage instance, it should automatically contact the identity provider, find its login URL, and the public keys that will be used to check the validity of its identity tokens. -The next time an user loads page on your SQLPage website, they will be redirected to -the provider's login page. Upon successful login, they will be redirected back to -the page they were initially requesting on your website. +By default, all pages on your website will now require users to log in. ## Access User Information in Your SQL @@ -89,6 +86,40 @@ values (sqlpage.path(), sqlpage.user_info('sub')); ``` +## Restricting authentication to a specific set of pages + +Sometimes, you don't want to protect your entire website with a login, but only a specific section. +You can achieve this by adding the `oidc_protected_paths` option to your `sqlpage.json` file. + +This option takes a list of URL prefixes. If a user requests a page whose address starts with one of these prefixes, they will be required to log in. + +**Example:** Protect only pages in the `/admin` folder. + +```json +{ + "oidc_issuer_url": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "host": "localhost:8080", + "oidc_protected_paths": ["/admin"] +} +``` + +In this example, a user visiting `/admin/dashboard.sql` will be prompted to log in, while a user visiting `/index.sql` will not. + +### Creating a public login page + +A common pattern is to have a public home page with a "Login" button that redirects users to a protected area. + +With the configuration above, you can create a public page `login.sql` that is not in a protected path. This page can contain a simple link to a protected resource, for instance `/admin/index.sql`: + +```sql +select 'list' as component, 'actions' as title; +select 'Login' as title, '/admin' as link, 'login' as icon; +``` + +When a non-authenticated user clicks this "Login" link, SQLPage will automatically redirect them to your identity provider's login page. After they successfully authenticate, they will be sent back to the page they originally requested (`/admin/index.sql`). + ## Going to Production When deploying to production: @@ -127,4 +158,4 @@ When deploying to production: - Verify your OIDC provider's logs for authentication attempts - In production, confirm your domain name matches exactly in both the OIDC provider settings and `sqlpage.json` - If [using a reverse proxy](/your-first-sql-website/nginx.sql), ensure it's properly configured to handle the OIDC callback path. -- If you have checked everything and you think the bug comes from SQLPage itself, [open an issue on our bug tracker](https://github.com/sqlpage/SQLPage/issues). +- If you have checked everything and you think the bug comes from SQLPage itself, [open an issue on our bug tracker](https://github.com/sqlpage/SQLPage/issues). \ No newline at end of file diff --git a/examples/single sign on/README.md b/examples/single sign on/README.md index be262e5c..c947d363 100644 --- a/examples/single sign on/README.md +++ b/examples/single sign on/README.md @@ -20,7 +20,7 @@ Depending on your use case, you can choose the appropriate protocol for your app To run the demo, you just need docker and docker-compose installed on your machine. Then, run the following commands: ```bash -docker-compose up +docker compose up --watch ``` This will start a Keycloak server and a SQLPage server. You can access the SQLPage application at http://localhost:8080. @@ -46,14 +46,12 @@ SQLPage has built-in support for OIDC authentication since v0.35. This project demonstrates how to use it with the free and open source [Keycloak](https://www.keycloak.org/) OIDC provider. You can easily replace Keycloak with another OIDC provider, such as Google, or your enterprise OIDC provider, by following the steps in the [Configuration](#configuration) section. -### Important Note About OIDC Protection +### Public and Protected Pages -When using SQLPage's built-in OIDC support, the entire website is protected behind authentication. This means: -- All pages require users to be logged in -- There is no way to have public pages alongside protected pages -- Users will be automatically redirected to the OIDC provider's login page when accessing any page +By default, SQLPage's built-in OIDC support protects the entire website. However, you can configure it to have a mix of public and protected pages using the `oidc_protected_paths` option in your `sqlpage.json` file. + +This allows you to create a public-facing area (like a homepage with a login button) and a separate protected area for authenticated users. -If you need to have a mix of public and protected pages, you should use the [authentication component](/component.sql?component=authentication) instead. ### Configuration @@ -65,7 +63,8 @@ you need to configure it in your `sqlpage.json` file: "oidc_issuer_url": "https://your-keycloak-server/auth/realms/your-realm", "oidc_client_id": "your-client-id", "oidc_client_secret": "your-client-secret", - "host": "localhost:8080" + "host": "localhost:8080", + "oidc_protected_paths": ["/protected"] } ``` @@ -91,15 +90,11 @@ select 'text' as component, 'Welcome, ' || sqlpage.user_info('name') || '!' as c The demo includes several SQL files that demonstrate different aspects of OIDC integration: -1. `index.sql`: Shows how to: - - Display user information using `sqlpage.user_info('email')` - - Show all available user information using `sqlpage.id_token()` +1. `index.sql`: A public page that shows a welcome message and a login button. If the user is logged in, it displays their email and a link to the protected page. -2. `protected.sql`: Demonstrates a page that is accessible to authenticated users +2. `protected.sql`: A page that is only accessible to authenticated users. It displays the user's information. -3. `logout.sql`: Shows how to: - - Remove the authentication cookie - - Redirect to the OIDC provider's logout endpoint +3. `logout.sql`: Logs the user out by removing the authentication cookie and redirecting to the OIDC provider's logout page. ### Docker Setup @@ -120,4 +115,3 @@ The demo uses Docker Compose to set up both SQLPage and Keycloak. The configurat - [SQLPage OIDC Documentation](https://sql-page.com/sso) - [OpenID Connect](https://openid.net/connect/) - [Authorization Code Flow](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) - diff --git a/examples/single sign on/docker-compose.yaml b/examples/single sign on/docker-compose.yaml index 5c372ff8..c54f9507 100644 --- a/examples/single sign on/docker-compose.yaml +++ b/examples/single sign on/docker-compose.yaml @@ -9,6 +9,8 @@ services: sqlpage: image: lovasoa/sqlpage:main # Use the latest development version of SQLPage + build: + context: ../.. volumes: - .:/var/www - ./sqlpage:/etc/sqlpage @@ -23,6 +25,10 @@ services: depends_on: keycloak: condition: service_healthy + develop: + watch: + - action: restart + path: ./sqlpage/ keycloak: build: diff --git a/examples/single sign on/index.sql b/examples/single sign on/index.sql index 09e3785d..4557d187 100644 --- a/examples/single sign on/index.sql +++ b/examples/single sign on/index.sql @@ -1,16 +1,18 @@ -set user_email = sqlpage.user_info('email'); +SELECT 'shell' as component, 'My public app' as title; -select 'shell' as component, 'My secure app' as title, - 'logout' as menu_item; +set email = sqlpage.user_info('email'); -select 'text' as component, - 'You''re in !' as title, - 'You are logged in as *`' || $user_email || '`*. -You have access to the [protected page](protected.sql). +-- For anonymous users +SELECT 'hero' as component, + '/protected' as link, + 'Log in' as link_text, + 'Welcome' as title, + 'You are currently browsing as a guest. Log in to access the protected page.' as description, + '/protected/public/hello.jpeg' as image +WHERE $email IS NULL; -![open door](/assets/welcome.jpeg)' - as contents_md; - -select 'list' as component; -select key as title, value as description -from json_each(sqlpage.id_token()); \ No newline at end of file +-- For logged-in users +SELECT 'text' as component, + 'Welcome back, ' || sqlpage.user_info('name') || '!' as title, + 'You are logged in as ' || sqlpage.user_info('email') || '. You can now access the [protected page](/protected) or [log out](/logout).' as contents_md +WHERE $email IS NOT NULL; diff --git a/examples/single sign on/protected.sql b/examples/single sign on/protected.sql deleted file mode 100644 index 1b8bd3bd..00000000 --- a/examples/single sign on/protected.sql +++ /dev/null @@ -1,8 +0,0 @@ -select 'card' as component, 'My secure protected page' as title, 1 as columns; - -select - 'Secret video' as title, - 'https://www.youtube.com/embed/mXdgmSdaXkg' as embed, - 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share' as allow, - 'iframe' as embed_mode, - '700' as height; \ No newline at end of file diff --git a/examples/single sign on/protected/index.sql b/examples/single sign on/protected/index.sql new file mode 100644 index 00000000..61a039cb --- /dev/null +++ b/examples/single sign on/protected/index.sql @@ -0,0 +1,17 @@ +set user_email = sqlpage.user_info('email'); + +select 'shell' as component, 'My secure app' as title, + 'logout' as menu_item; + +select 'text' as component, + 'You''re in, '|| sqlpage.user_info('name') || ' !' as title, + 'You are logged in as *`' || $user_email || '`*. + +You have access to this protected page. + +![open door](/assets/welcome.jpeg)' + as contents_md; + +select 'list' as component; +select key as title, value as description +from json_each(sqlpage.user_info_token()); diff --git a/examples/single sign on/protected/public/hello.jpeg b/examples/single sign on/protected/public/hello.jpeg new file mode 100644 index 00000000..d68a023a Binary files /dev/null and b/examples/single sign on/protected/public/hello.jpeg differ diff --git a/examples/single sign on/sqlpage/sqlpage.yaml b/examples/single sign on/sqlpage/sqlpage.yaml index b2e42599..98f0163e 100644 --- a/examples/single sign on/sqlpage/sqlpage.yaml +++ b/examples/single sign on/sqlpage/sqlpage.yaml @@ -1,3 +1,7 @@ oidc_issuer_url: http://localhost:8181/realms/sqlpage_demo oidc_client_id: sqlpage oidc_client_secret: qiawfnYrYzsmoaOZT28rRjPPRamfvrYr # For a safer setup, use environment variables to store this +oidc_protected_paths: + - /protected # Makes the website root is publicly accessible, requiring authentication only for the /protected path +oidc_public_paths: + - /protected/public # Adds an exception for the /protected/public path, which is publicly accessible too diff --git a/src/app_config.rs b/src/app_config.rs index f6ff1c89..6b2ef78d 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -201,6 +201,28 @@ pub struct AppConfig { #[serde(default = "default_oidc_scopes")] pub oidc_scopes: String, + /// Defines a list of path prefixes that should be protected by OIDC authentication. + /// By default, all paths are protected. + /// If you specify a list of prefixes, only requests whose path starts with one of the prefixes will require authentication. + /// For example, if you set this to `["/private"]`, then requests to `/private/some_page.sql` will require authentication, + /// but requests to `/index.sql` will not. + /// NOTE: `OIDC_PUBLIC_PATHS` takes precedence over `OIDC_PROTECTED_PATHS`. + /// For example, if you have `["/private"]` on the `protected_paths` like before, but also `["/private/public"]` on the `public_paths`, then `/private` requires authentication, but `/private/public` requires not authentication. + /// You cannot make a path inside a public path private again. So expanding the previous example, if you now add `/private/public/private_again`, then this path will still be accessible. + #[serde(default = "default_oidc_protected_paths")] + pub oidc_protected_paths: Vec, + + /// Defines path prefixes to exclude from OIDC authentication. + /// By default, no paths are excluded. + /// Paths matching these prefixes will not require authentication. + /// For example, if set to `["/public"]`, requests to `/public/some_page.sql` will not require authentication, + /// but requests to `/index.sql` will still require it. + /// To make `/protected/public.sql` public while protecting its containing directory, + /// set `oidc_public_paths` to `["/protected/public.sql"]` and `oidc_protected_paths` to `["/protected"]`. + /// Be aware that any path starting with `/protected/public.sql` (e.g., `/protected/public.sql.backup`) will also become public. + #[serde(default)] + pub oidc_public_paths: Vec, + /// A domain name to use for the HTTPS server. If this is set, the server will perform all the necessary /// steps to set up an HTTPS server automatically. All you need to do is point your domain name to the /// server's IP address. @@ -546,6 +568,10 @@ fn default_oidc_scopes() -> String { "openid email profile".to_string() } +fn default_oidc_protected_paths() -> Vec { + vec!["/".to_string()] +} + #[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum DevOrProd { diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index ea0d1a3d..d4e53ef0 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -48,6 +48,8 @@ pub struct OidcConfig { pub issuer_url: IssuerUrl, pub client_id: String, pub client_secret: String, + pub protected_paths: Vec, + pub public_paths: Vec, pub app_host: String, pub scopes: Vec, } @@ -60,6 +62,8 @@ impl TryFrom<&AppConfig> for OidcConfig { let client_secret = config.oidc_client_secret.as_ref().ok_or(Some( "The \"oidc_client_secret\" setting is required to authenticate with the OIDC provider", ))?; + let protected_paths: Vec = config.oidc_protected_paths.clone(); + let public_paths: Vec = config.oidc_public_paths.clone(); let app_host = get_app_host(config); @@ -67,6 +71,8 @@ impl TryFrom<&AppConfig> for OidcConfig { issuer_url: issuer_url.clone(), client_id: config.oidc_client_id.clone(), client_secret: client_secret.clone(), + protected_paths, + public_paths, scopes: config .oidc_scopes .split_whitespace() @@ -77,6 +83,14 @@ impl TryFrom<&AppConfig> for OidcConfig { } } +impl OidcConfig { + #[must_use] + pub fn is_public_path(&self, path: &str) -> bool { + !self.protected_paths.iter().any(|p| path.starts_with(p)) + || self.public_paths.iter().any(|p| path.starts_with(p)) + } +} + fn get_app_host(config: &AppConfig) -> String { if let Some(host) = &config.host { return host.clone(); @@ -178,7 +192,11 @@ pub struct OidcService { oidc_state: Arc, } -impl OidcService { +impl OidcService +where + S: Service, Error = Error>, + S::Future: 'static, +{ pub fn new(service: S, oidc_state: Arc) -> Self { Self { service, @@ -196,6 +214,14 @@ impl OidcService { return self.handle_oidc_callback(request); } + if self.oidc_state.config.is_public_path(request.path()) { + log::debug!( + "The request path {} is not in a public path, skipping OIDC authentication", + request.path() + ); + return Box::pin(self.service.call(request)); + } + log::debug!("Redirecting to OIDC provider"); let response = build_auth_provider_redirect_response( @@ -241,6 +267,7 @@ where fn call(&self, request: ServiceRequest) -> Self::Future { log::trace!("Started OIDC middleware request handling"); + let oidc_client = Arc::clone(&self.oidc_state.client); match get_authenticated_user_info(&oidc_client, &request) { Ok(Some(claims)) => {