chore: import upstream snapshot with attribution
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# User authentication demo
|
||||
|
||||
This example demonstrates how to handle user authentication with SQLpage.
|
||||
|
||||
It uses a PostgreSQL database to store user information and session ids,
|
||||
but the same principles can be applied to other databases.
|
||||
|
||||
All the user and password management is done in SQLPage,
|
||||
which uses [best practices](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#maximum-password-lengths) for password storage.
|
||||
|
||||
This demonstrates how to implement:
|
||||
- [a signup form](./signup.sql)
|
||||
- [a login form](./signin.sql)
|
||||
- [a logout button](./logout.sql)
|
||||
- [secured pages](./protected_page.sql) that can only be accessed by logged-in users
|
||||
|
||||
User authentication is a complex topic, and you can follow the work on implementing differenet authentication methods in [this issue](https://github.com/sqlpage/SQLPage/issues/12).
|
||||
|
||||
## How to run
|
||||
|
||||
Install [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
Then run the following command in this directory:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Then open [http://localhost:8080](http://localhost:8080) in your browser.
|
||||
|
||||
## Caveats
|
||||
|
||||
In this example, we handle user creation and login in SQLpage.
|
||||
|
||||
If you are implementing user authentication in an public application with potentially sensitive data,
|
||||
you should propably read the [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) from OWASP.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Signup form | Login form | Protected page |
|
||||
| --- | --- | --- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
## How it works
|
||||
|
||||
### User creation
|
||||
|
||||
The [signup form](./signup.sql) is a simple form that is handled by [`create_user.sql`](./create_user.sql).
|
||||
You could restrict user creation to existing administrators and create an initial administrator in a database migration.
|
||||
|
||||
### User login
|
||||
|
||||
The [login form](./signin.sql) is a simple form that is handled by [`login.sql`](./login.sql).
|
||||
|
||||
`login.sql` checks that the username exists and that the password is correct using the [authentication component](https://sql-page.com/documentation.sql?component=authentication#component) extension with
|
||||
|
||||
```sql
|
||||
SELECT 'authentication' AS component,
|
||||
'signin.sql' AS link,
|
||||
(SELECT password_hash FROM user_info WHERE username = :username) AS password_hash,
|
||||
:password AS password;
|
||||
```
|
||||
|
||||
If the login is successful, an entry is added to the [`login_session`](./sqlpage/migrations/0000_init.sql) table with a random session id.
|
||||
|
||||
If it is not, the authentication component will redirect the user to the login page and stop the execution of the page.
|
||||
|
||||
The session id is then stored in a cookie on the user's browser.
|
||||
|
||||
The user is then redirected to [`./protected_page.sql`](./protected_page.sql) which will check that the user is logged in.
|
||||
|
||||
### Protected pages
|
||||
|
||||
Protected pages are pages that can only be accessed by logged-in users.
|
||||
|
||||
There is an example in [`protected_page.sql`](./protected_page.sql) that uses
|
||||
the [`redirect`](https://sql-page.com/documentation.sql?component=redirect#component)
|
||||
component to redirect the user to the login page if they are not logged in.
|
||||
|
||||
Checking whether the user is logged in is as simple as checking that session id returned by [`sqlpage.cookie('session')`](https://sql-page.com/functions.sql?function=cookie#function) exists in the [`login_session`](./sqlpage/migrations/0000_init.sql) table.
|
||||
|
||||
|
||||
### User logout
|
||||
|
||||
The cookie can be deleted in the browser by navigating to [`./logout.sql`](./logout.sql).
|
||||
@@ -0,0 +1,10 @@
|
||||
INSERT INTO user_info (username, password_hash)
|
||||
VALUES (:username, sqlpage.hash_password(:password))
|
||||
ON CONFLICT (username) DO NOTHING
|
||||
RETURNING
|
||||
'redirect' AS component,
|
||||
'create_user_welcome_message.sql?username=' || :username AS link;
|
||||
|
||||
-- If we are still here, it means that the user was not created
|
||||
-- because the username was already taken.
|
||||
SELECT 'redirect' AS component, 'create_user_welcome_message.sql?error&username=' || :username AS link;
|
||||
@@ -0,0 +1,15 @@
|
||||
SELECT 'hero' AS component,
|
||||
'Welcome' AS title,
|
||||
'Welcome, ' || $username || '! Your user account was successfully created. You can now log in.' AS description,
|
||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Community_wp20.png/974px-Community_wp20.png' AS image,
|
||||
'signin.sql' AS link,
|
||||
'Log in' AS link_text
|
||||
WHERE $error IS NULL;
|
||||
|
||||
SELECT 'hero' AS component,
|
||||
'Sorry' AS title,
|
||||
'Sorry, the user name "' || $username || '" is already taken.' AS description,
|
||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Sad_face_of_a_Wayuu_Woman.jpg/640px-Sad_face_of_a_Wayuu_Woman.jpg' AS image,
|
||||
'signup.sql' AS link,
|
||||
'Try again' AS link_text
|
||||
WHERE $error IS NOT NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- ./sqlpage:/etc/sqlpage
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: postgres://root:secret@db/sqlpage
|
||||
db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases
|
||||
image: postgres
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_DB: sqlpage
|
||||
POSTGRES_PASSWORD: secret
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT 'form' AS component;
|
||||
SELECT 'password' AS name, 'Password to create a hash for' AS label, :password AS value;
|
||||
|
||||
SELECT 'code' AS component;
|
||||
SELECT sqlpage.hash_password(:password) AS contents;
|
||||
@@ -0,0 +1,15 @@
|
||||
SELECT 'shell' AS component,
|
||||
'User Management App' AS title,
|
||||
'user' AS icon,
|
||||
'/' AS link,
|
||||
CASE COALESCE(sqlpage.cookie('session'), '')
|
||||
WHEN '' THEN '["signin", "signup"]'::json
|
||||
ELSE '["logout"]'::json
|
||||
END AS menu_item;
|
||||
|
||||
SELECT 'hero' AS component,
|
||||
'SQLPage Authentication Demo' AS title,
|
||||
'This application requires signing up to view the protected page.' AS description_md,
|
||||
'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Community_wp20.png/974px-Community_wp20.png' AS image,
|
||||
'protected_page.sql' AS link,
|
||||
'Access protected page' AS link_text;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The authentication component will stop the execution of the page and redirect the user to the login page if
|
||||
-- the password is incorrect or if the user does not exist.
|
||||
SELECT 'authentication' AS component,
|
||||
'signin.sql?error' AS link,
|
||||
(SELECT password_hash FROM user_info WHERE username = :username) AS password_hash,
|
||||
:password AS password;
|
||||
|
||||
-- Generate a random 32 characters session ID, insert it into the database,
|
||||
-- and save it in a cookie on the user's browser.
|
||||
INSERT INTO login_session (id, username)
|
||||
VALUES (sqlpage.random_string(32), :username)
|
||||
RETURNING
|
||||
'cookie' AS component,
|
||||
'session' AS name,
|
||||
id AS value,
|
||||
FALSE AS secure; -- You can remove this if the site is served over HTTPS.
|
||||
|
||||
-- Redirect the user to the protected page.
|
||||
SELECT 'redirect' AS component, 'protected_page.sql' AS link;
|
||||
@@ -0,0 +1,4 @@
|
||||
DELETE FROM login_session WHERE id = sqlpage.cookie('session');
|
||||
SELECT 'cookie' AS component, 'session' AS name, TRUE AS remove;
|
||||
|
||||
SELECT 'redirect' AS component, '/' AS link;
|
||||
@@ -0,0 +1,12 @@
|
||||
SET username = (SELECT username FROM login_session WHERE id = sqlpage.cookie('session'));
|
||||
|
||||
SELECT 'redirect' AS component,
|
||||
'signin.sql?error' AS link
|
||||
WHERE $username IS NULL;
|
||||
|
||||
SELECT 'shell' AS component, 'Protected page' AS title, 'lock' AS icon, '/' AS link, 'logout' AS menu_item;
|
||||
|
||||
SELECT 'text' AS component,
|
||||
'Welcome, ' || $username || ' !' AS title,
|
||||
'This content is [top secret](https://youtu.be/dQw4w9WgXcQ).
|
||||
You cannot view it if you are not connected.' AS contents_md;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 610 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,9 @@
|
||||
SELECT 'login' AS component,
|
||||
'login.sql' AS action,
|
||||
'Sign in' AS title,
|
||||
'Username' AS username,
|
||||
'Password' AS password,
|
||||
'user' AS username_icon,
|
||||
'lock' AS password_icon,
|
||||
case when $error is not null then 'We could not authenticate you. Please log in or [create an account](signup.sql).' end as error_message_md,
|
||||
'Sign in' AS validate;
|
||||
@@ -0,0 +1,8 @@
|
||||
SELECT 'form' AS component,
|
||||
'Create a new user account' AS title,
|
||||
'Sign up' AS validate,
|
||||
'create_user.sql' AS action;
|
||||
|
||||
SELECT 'username' AS name;
|
||||
SELECT 'password' AS name, 'password' AS type, '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$' AS pattern, 'Password must be at least 8 characters long and contain at least one letter and one number.' AS description;
|
||||
SELECT 'terms' AS name, 'I accept the terms and conditions' AS label, TRUE AS required, FALSE AS value, 'checkbox' AS type;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE user_info (
|
||||
username TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE login_session (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL REFERENCES user_info(username),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Creates an initial user called 'admin'
|
||||
-- with a password hash that was generated using the 'generate_password_hash.sql' page.
|
||||
INSERT INTO user_info (username, password_hash)
|
||||
VALUES ('admin', '$argon2id$v=19$m=19456,t=2,p=1$IiReWDP0ocWvia+fTdozJw$53EozOKX7HkpvOdoWHjsh9yKvRN2TmQm/PjYBeaOqqc');
|
||||
@@ -0,0 +1,59 @@
|
||||
GET http://localhost:8080
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "SQLPage Authentication Demo"
|
||||
body contains "Access protected page"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/signin.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Sign in"
|
||||
body contains "Username"
|
||||
body contains "Password"
|
||||
|
||||
GET http://localhost:8080/protected_page.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "signin.sql?error"
|
||||
|
||||
GET http://localhost:8080/signin.sql?error
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "We could not authenticate you"
|
||||
body htmlUnescape contains "href=\"signup.sql\""
|
||||
|
||||
POST http://localhost:8080/create_user.sql
|
||||
[FormParams]
|
||||
username: hurl_user
|
||||
password: Password123
|
||||
terms: on
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "create_user_welcome_message.sql?username=hurl_user"
|
||||
|
||||
POST http://localhost:8080/login.sql
|
||||
[FormParams]
|
||||
username: hurl_user
|
||||
password: Password123
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
cookie "session" exists
|
||||
header "Location" contains "protected_page.sql"
|
||||
|
||||
GET http://localhost:8080/protected_page.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Welcome, hurl_user"
|
||||
body contains "top secret"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/logout.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "/"
|
||||
|
||||
GET http://localhost:8080/protected_page.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "signin.sql?error"
|
||||
Reference in New Issue
Block a user