diff --git a/examples/user-authentication/README.md b/examples/user-authentication/README.md new file mode 100644 index 00000000..f761b236 --- /dev/null +++ b/examples/user-authentication/README.md @@ -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). \ No newline at end of file diff --git a/examples/user-authentication/create_user.sql b/examples/user-authentication/create_user.sql index 3f197609..95b5e1d3 100644 --- a/examples/user-authentication/create_user.sql +++ b/examples/user-authentication/create_user.sql @@ -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; \ No newline at end of file +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); \ No newline at end of file diff --git a/examples/user-authentication/index.sql b/examples/user-authentication/index.sql index f406f570..8b31d8da 100644 --- a/examples/user-authentication/index.sql +++ b/examples/user-authentication/index.sql @@ -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; \ No newline at end of file diff --git a/examples/user-authentication/login.sql b/examples/user-authentication/login.sql index 3f163d9c..e2a0d2c5 100644 --- a/examples/user-authentication/login.sql +++ b/examples/user-authentication/login.sql @@ -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; \ No newline at end of file + 'login_check.sql' AS location; \ No newline at end of file diff --git a/examples/user-authentication/login_check.sql b/examples/user-authentication/login_check.sql new file mode 100644 index 00000000..5b290211 --- /dev/null +++ b/examples/user-authentication/login_check.sql @@ -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; \ No newline at end of file diff --git a/examples/user-authentication/logout.sql b/examples/user-authentication/logout.sql new file mode 100644 index 00000000..6e3d564f --- /dev/null +++ b/examples/user-authentication/logout.sql @@ -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; \ No newline at end of file diff --git a/examples/user-authentication/protected_page.sql b/examples/user-authentication/protected_page.sql index 19354272..bfcb0ed0 100644 --- a/examples/user-authentication/protected_page.sql +++ b/examples/user-authentication/protected_page.sql @@ -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'); \ No newline at end of file + 'This content is [top secret](https://youtu.be/dQw4w9WgXcQ). You cannot view it if you are not connected.' AS contents_md; \ No newline at end of file diff --git a/examples/user-authentication/screenshots/duplicate-user.png b/examples/user-authentication/screenshots/duplicate-user.png new file mode 100644 index 00000000..a9180ff3 Binary files /dev/null and b/examples/user-authentication/screenshots/duplicate-user.png differ diff --git a/examples/user-authentication/screenshots/homepage.png b/examples/user-authentication/screenshots/homepage.png new file mode 100644 index 00000000..e840cac9 Binary files /dev/null and b/examples/user-authentication/screenshots/homepage.png differ diff --git a/examples/user-authentication/screenshots/secret.png b/examples/user-authentication/screenshots/secret.png new file mode 100644 index 00000000..9e14d999 Binary files /dev/null and b/examples/user-authentication/screenshots/secret.png differ diff --git a/examples/user-authentication/screenshots/signin.png b/examples/user-authentication/screenshots/signin.png new file mode 100644 index 00000000..9e96dc20 Binary files /dev/null and b/examples/user-authentication/screenshots/signin.png differ diff --git a/examples/user-authentication/screenshots/signup-success.png b/examples/user-authentication/screenshots/signup-success.png new file mode 100644 index 00000000..a5841036 Binary files /dev/null and b/examples/user-authentication/screenshots/signup-success.png differ diff --git a/examples/user-authentication/screenshots/signup.png b/examples/user-authentication/screenshots/signup.png new file mode 100644 index 00000000..f1f90e6b Binary files /dev/null and b/examples/user-authentication/screenshots/signup.png differ diff --git a/examples/user-authentication/sqlpage/migrations/0000_init.sql b/examples/user-authentication/sqlpage/migrations/0000_init.sql index a96e8489..7a1851b2 100644 --- a/examples/user-authentication/sqlpage/migrations/0000_init.sql +++ b/examples/user-authentication/sqlpage/migrations/0000_init.sql @@ -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 -); \ No newline at end of file +); + + +-- 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; \ No newline at end of file