Implement the full user authentication flow in the example

This commit is contained in:
lovasoa
2023-07-07 20:07:25 +02:00
parent 91aebb2089
commit c9f14280cf
14 changed files with 105 additions and 15 deletions
+58
View File
@@ -0,0 +1,58 @@
# User authentication demo
This example demonstrates how to manually handle user authentication with SQLpage and PostgreSQL.
All the user and password management is done in the database, using the standard [pgcrypto](https://www.postgresql.org/docs/current/pgcrypto.html) postgresql extension.
This demonstrates how to implement:
- [a signup form](./sign%20up.sql)
- [a login form](./sign%20in.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/lovasoa/SQLpage/issues/12).
## Caveats
In this example, we store encrypted user passwords in the database, but we let the database itself handle the encryption.
For that to be safe, you need to make sure that:
- the database is not accessible by untrusted users
- the database logs and configuration files are not accessible by untrusted users
- your connection to the database is encrypted [(use SSL)](https://www.postgresql.org/docs/current/ssl-tcp.html). It should be the case by default if you use a recent version of PostgreSQL and a popular distribution.
## Screenshots
| Signup form | Login form | Protected page |
| --- | --- | --- |
| ![signup form](./screenshots/signup.png) | ![login form](./screenshots/signin.png) | ![protected page](./screenshots/secret.png) |
| ![home](./screenshots/homepage.png) | ![duplicate username](./screenshots/duplicate-user.png) | ![signup success](./screenshots/signup-success.png) |
## How it works
### User creation
The [a signup form](./sign%20up.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 [a login form](./sign%20in.sql) is a simple form that is handled by [`login.sql`](./login.sql).
It checks that the username exists and that the password is correct using the [pgcrypto](https://www.postgresql.org/docs/current/pgcrypto.html) extension with
```sql
SELECT * FROM users WHERE username = :username AND password = crypt(:password, 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.
The session id is then stored in a cookie on the user's browser.
The user is then redirected to [`./check_login.sql`](./check_login.sql) that checks that the session id is valid and redirects back to the login page if it is not.
### 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 a simple [postgresql stored procedure](./sqlpage/migrations/0000_init.sql)
to raise an error (and thus prevent content rendering) if the user is not logged in.
### User logout
The cookie can be deleted in the browser by navigating to [`./logout.sql`](./logout.sql).
+15 -5
View File
@@ -4,8 +4,18 @@ WITH inserted_user AS (
ON CONFLICT (username) DO NOTHING
RETURNING username
)
SELECT 'text' AS component,
COALESCE(
'Welcome, ' || (SELECT username FROM inserted_user) || '! Your user account was successfully created. You can now [log in](sign%20in.sql).',
'Sorry, this user name is already taken.'
) AS contents_md;
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,
'sign in.sql' AS link,
'Log in' AS link_text
FROM inserted_user
UNION ALL
SELECT 'hero' AS component,
'Sorry' AS title,
'Sorry, this user name is already taken.' AS description_md,
'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,
'sign up.sql' AS link,
'Try again' AS link_text
WHERE NOT EXISTS (SELECT 1 FROM inserted_user);
+1 -1
View File
@@ -9,5 +9,5 @@ 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,
'login_check.sql' AS link,
'Access protected page' AS link_text;
+1 -2
View File
@@ -6,6 +6,5 @@ WHERE username = :username
RETURNING 'cookie' AS component,
'session' AS name,
id AS value;
SELECT 'http_header' AS component,
'protected_page.sql' AS location;
'login_check.sql' AS location;
@@ -0,0 +1,6 @@
-- Checks if the login was successful, and redirects to the right page.
SELECT 'http_header' AS component,
CASE WHEN is_valid_session(sqlpage.cookie('session'))
THEN 'protected_page.sql'
ELSE 'sign in.sql'
END AS location;
+4
View File
@@ -0,0 +1,4 @@
DELETE FROM login_session WHERE id = sqlpage.cookie('session');
SELECT 'cookie' AS component, 'session' AS name, TRUE AS remove;
SELECT 'http_header' AS component, 'login.sql' AS location;
@@ -1,8 +1,6 @@
SELECT raise_error('Invalid credentials, please log in') WHERE NOT is_valid_session(sqlpage.cookie('session'));
SELECT 'shell' AS component, 'Protected page' AS title, 'lock' AS icon, '/' AS link, 'logout' AS menu_item;
SELECT 'text' AS component,
'This content is [top secret](https://youtu.be/dQw4w9WgXcQ). You cannot view it if you are not connected.' AS contents_md;
SELECT EXISTS(SELECT 1 FROM login_session WHERE id=sqlpage.cookie('session')) AS contents;
SELECT 'debug' AS component;
SELECT * FROM login_session;
SELECT sqlpage.cookie('session');
'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

@@ -10,4 +10,19 @@ CREATE TABLE login_session (
id TEXT PRIMARY KEY DEFAULT encode(gen_random_bytes(128), 'hex'),
username TEXT NOT NULL REFERENCES user_info(username),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
);
-- Returns true if the session is valid, false otherwise
CREATE FUNCTION is_valid_session(user_session text) RETURNS boolean AS $$
BEGIN
RETURN EXISTS(SELECT 1 FROM login_session WHERE id=user_session);
END;
$$ LANGUAGE plpgsql;
-- Takes a session id, does nothing if it is valid, throws an error otherwise.
CREATE FUNCTION raise_error(error_message_text text) RETURNS void AS $$
BEGIN
RAISE EXCEPTION '%', error_message_text;
END;
$$ LANGUAGE plpgsql;