kleindan.dev

Sesje użytkownika oraz autentykacja

Dziś przyjrzymy się najbardziej podstawowemu modelowi autentykacji użytkowników przy pomocy loginu i hasła oraz wykorzystamy cookies do przechowania informacji o jego tożsamości.

Wideo towarzyszące

Sesja użytkownika

Jeżeli chcemy przechowywać jakieś dane o użytkowniku po stronie serwera nie ujawniając ich, możemy utworzyć na serwerze tak zwany obiekt sesji. Obiekt sesji ma identyfikator, który odeślemy użytkownikowi, a poza tym może zawierać dowolne dane. Pierwszą informacją którą umieścimy w sesji użytkownika będzie informacja o jego tożsamości, ale o tym za chwilę.

Identyfikator sesji

Ponieważ obiekt sesji będzie powiązany z tożsamością użytkownika musimy w miarę możliwości uchronić jego identyfikator przed ujawnieniem osobom trzecim oraz zabezpieczyć przed zgadnięciem. Gdyby sesje były numerowane kolejno, użytkownik mógłby podmienić swój identyfikator na jeden numer większy lub mniejszy tym samym podszywając się po kogoś innego. Dlatego identyfikator sesji będzie dużą, losowo generowaną liczbą. Organizacja OWASP rekomenduje 64 bity entropii dla identyfikatora sesji, co dla losowych liczb całkowitych oznacza po prostu liczbę 64-bitową.

Liczby 64-bitowe w JavaScript

Niestety domyślny sposób reprezentacji liczby w JavaScript jest oparty o liczby zmiennoprzecinkowe i pozwala na dokładne reprezentowanie liczb całkowitych na maksymalnie 53 bitach. Żeby zrozumieć o czym mowa, możemy uruchomić w terminalu REPL JavaScript poleceniem ’node’ i wykonajmy parę eksperymentów:

> node
Welcome to Node.js v22.22.0.
Type ".help" for more information.
> ((1n << 64n) - 1n); // maksymalna wartość zapisywalna na 64 bitach
18446744073709551615n
> ((1n << 64n) - 1n).toString(16); // wszystkie 64 bity ustawione
'ffffffffffffffff'
> Number.MAX_SAFE_INTEGER; // maksymalna liczba którą bezpiecznie możemy zapisać w typie Number
9007199254740991
> (Number.MAX_SAFE_INTEGER + 1) === (Number.MAX_SAFE_INTEGER + 2) // powyżej zaczyna spadać precyzja
true

W związku z powyższym musimy albo użyć typu BigInt do zapisu identyfikatora sesji, albo zignorować rekomendację OWASP i zdecydować się na mniejszy zakres potencjalnych identyfikatorów. Ponieważ zastosowanie BigInt będzie bardziej problematyczne, a to są materiały edukacyjne, zdecydujemy się na trudniejszą opcję i zobaczymy, jak bardzo to skomplikuje nasz kod. Jeżeli dotrzemy do ściany, zawsze możemy wrócić do łatwiejszej opcji.

Jeden z problemów z BigInt polega na tym, że funkcje związane z serializacją danych do formatu JSON nie radzą sobie ze zmiennymi typu BigInt. Jest to o tyle kłopot, że np. cookie-parser korzysta z funkcji JSON.parse oraz JSON.stringify w swojej implementacji, więc będziemy musieli być ostrożni w przekazywaniu danych poza nasz kod.

> node
Welcome to Node.js v22.22.0.
Type ".help" for more information.
> JSON.stringify({ foo: 1n });
Uncaught TypeError: Do not know how to serialize a BigInt
    at JSON.stringify (<anonymous>)

Drugi problem polega na tym, że moduł node:sqlite domyślnie czyta liczby całkowite z bazy danych jako typ Number. Możemy to obejść dodając do wywołania konstruktora klasy DatabaseSync. Natomiast wtedy każda liczba całkowita jest czytana jako BigInt i będziemy musieli to odpowiednio obsłużyć.

Implementacja modelu sesji użytkownika
> models/session.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import { DatabaseSync } from "node:sqlite";
import { randomBytes } from "node:crypto";

const db_path = "./db.sqlite";
const db = new DatabaseSync(db_path, { readBigInts: true });

const SESSION_COOKIE = "__Host-fisz-id";
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;

// TODO(kleindan) no user model yet
// remember to add Foreign Key relations later
db.exec(`
  CREATE TABLE IF NOT EXISTS fc_session (
    id              INTEGER PRIMARY KEY,
    user_id         INTEGER,
    created_at      INTEGER
  ) STRICT;
  `);

const db_ops = {
  create_session: db.prepare(
    `INSERT INTO fc_session (id, user_id, created_at)
            VALUES (?, ?, ?) RETURNING id, user_id, created_at;`
  ),
  get_session: db.prepare(
    "SELECT id, user_id, created_at from fc_session WHERE id = ?;"
  ),
};

function createSession(user, res) {
  let sessionId = randomBytes(8).readBigInt64BE();
  let createdAt = Date.now();

  let session = db_ops.create_session.get(sessionId, user, createdAt);
  res.locals.session = session;

  res.cookie(SESSION_COOKIE, session.id.toString(), {
    maxAge: ONE_WEEK,
    httpOnly: true,
    secure: true,
  });
  return session;
}

function sessionHandler(req, res, next) {
  let sessionId = req.cookies[SESSION_COOKIE];
  let session = null;
  if (sessionId != null) {
    if (!sessionId.match(/^-?[0-9]+$/)) {
      // Invalid session id
      sessionId = null;
    } else {
      sessionId = BigInt(sessionId);
    }
  }

  // sessionId may look valid but might not exist in db
  if (sessionId != null) session = db_ops.get_session.get(sessionId);
  
  if (session != null) {
    res.locals.session = session;
    
    res.cookie(SESSION_COOKIE, res.locals.session.id.toString(), {
      maxAge: ONE_WEEK,
      httpOnly: true,
      secure: true,
    });
  } else {
    session = createSession(null, res);
  }

  setImmediate(printUserSession);

  next();

  function printUserSession() {
    console.info(
      "Session:",
      session.id,
      "user:",
      session.user,
      "created at:",
      new Date(Number(session.created_at)).toISOString()
    );
  }
}

export default {
  createSession,
  sessionHandler,
};

Przeanalizuj powyższy kod, wykorzystujemy w nim poznane wcześniej techniki. Chciałem podkreślić i wyjaśnić tylko kilka rzeczy:

Zadanie praktyczne

Zmień powyższy kod tak aby logowanie informacji o sesji odbywało się w ramach przetwarzania żądania i sprawdź, czy wpływa to na czas odpowiedzi serwera. (Podpowiedź: logi morgan)

Autentykacja hasłem

Autentykacja to proces ustalania tożsamości użytkownika. Nic więcej. Pod koniec procesu autentykacji chcemy mieć pewność, że osoba korzystająca z naszej aplikacji jest tą samą osobą, która założyła w niej konto wcześniej. Przyjmiemy model autentykacji w oparciu o unikatowy identyfikator użytkownika (w skrócie login) i hasło. Są inne rozwiązania i warto poszerzyć w tym zakresie wiedzę, ale nadal najpopularniejszym rozwiązaniem pozostaje para login+hasło, więc zacznijmy od poznania podstaw.

(Nie)przechowywanie haseł na serwerze

Po pierwsze, nie przechowujemy haseł na serwerze. Po drugie i po trzecie nie przechowujemy haseł na serwerze. W bazie danych przechowujemy hash albo skrót hasła. Nigdy hasło bezpośrednio. Wynika to ze stosowania dobrych praktyk bezpieczeństwa. Gdybyśmy przechowywali w bazie danych hasło:

Przechowywanie skrótów na serwerze

Skróty (albo hashe) haseł uzyskujemy tak zwanymi funkcjami skrótu albo, bardziej potocznie, funkcjami haszującymi. Omawiany na ostatniej lekcji algorytm HMAC też korzystał z funkcji skrótu. Co do zasady funkcje skrótu sprowadzają się do zamienienia dowolnych danych wejściowych na jedną, zazwyczaj długą liczbę. Popularnych funkcji skrótu jest dużo, np. CRC, md5, SHA-1, bcrypt, itd. Każda z nich ma inne właściwości i trochę inne zastosowania.

Skróty haseł moglibyśmy teoretycznie liczyć dowolną funkcją skrótu, ale powinniśmy wybrać mądrze, albo przynajmniej polegając na mądrości ludzi, którzy się na bezpieczeństwie znają. TL;DR jest takie, że OWASP rekomenduje algorytm argon2id i dlatego spróbujemy go wykorzystać w naszej aplikacji.

Jakie właściwości powinna mieć funkcja skrótu, żeby ją zastosować do autentykacji użytkownika? Wbrew intuicji powinna być możliwie wolna i pożerać stosunkowo dużo zasobów komputera. Dlaczego? Ponieważ cały czas musimy mieć z tyłu głowy fakt, że nasza baza danych albo jej backup może kiedyś trafić w niepowołane ręce. Wtedy atakujący mając prawidłowe hashe haseł mogą przy pomocy ataku słownikowego albo brute force zgadnąć hasła użytkowników. Muszą znać tylko użytą przez nas funkcję skrótu. Jest to informacja nietrudna do pozyskania, nawet bez dostępu do kodu aplikacji. Wystarczy że atakujący utworzą wcześniej konto ze znanym sobie hasłem i na tej podstawie sprawdzą wyniki najpopularniejszych opcji.

Salt, pepper, rainbow tables

Kolejnym sposobem zwiększania bezpieczeństwa przechowywanych w naszej bazie danych skrótów jest wykorzystywanie podczas ich liczenia tzw. salt oraz pepper. Jedno i drugie jest zazwyczaj zbiorem losowych bajtów, które są dodawane do hasła na jakimś etapie haszowania. Różnica między nimi jest następująca:

Zaraz, jeżeli salt jest przechowywany w bazie danych, to w jaki sposób zwiększa bezpieczeństwo przechowywanych skrótów? Pamiętajmy, że funkcje skrótów są deterministyczne. Dla tych samych danych wejściowych dadzą zawsze ten sam wynik. Jeżeli nie wprowadzimy do procesu odrobiny losowości, jeżeli dwóch użytkowników ustawi sobie to samo hasło, hasze zapisane w bazie danych będą identyczne i będzie można łatwo je wyłowić.

Jeżeli funkcje haszujące dają te same wyniki dla tych samych danych wejściowych, moglibyśmy nawet przygotować listę skrótów wszystkich możliwych kombinacji znaków do określonej długości, a potem porównywać uzyskane wartości ze skrótami zapisanymi w bazie. O ile wydaje się to lekko szalone, o tyle taka technika jest stosowana przez atakujących. Te ogromne zbiory skrótów nazywane są rainbow tables i można nawet znaleźć w Internecie już przeliczone listy dla popularnych funkcji haszujących.

Dodając do hasła salt powodujemy, że trzeba by było przygotować osobną rainbow table dla każdej wartości salt. Jest to możliwe, ale jeżeli użyta przez nas funkcja haszująca jest zasobożerna, to staje się to niepraktyczne. Niepraktyczne w sensie “przeliczenie tego dla haseł o długości do 8 znaków zajmie na obecnie dostępnych komputerach ponad 10 000 lat”.

Jeżeli jeszcze do tego dołożymy pepper, którego atakujący naszą aplikację nie dadzą rady zdobyć, to możemy spać spokojnie jeżeli chodzi o bezpieczeństwo haseł naszych użytkowników.

Model użytkownika

Stworzymy sobie kolejny model, tym razem służący do przechowywania w aplikacji informacji o użytkowniku oraz skrótu jego hasła. Będziemy potrzebowali przynajmniej możliwości stworzenia użytkownika i odnalezienia użytkownika po identyfikatorze, oraz weryfikacji hasła użytkownika.

Wybranie implementacji argon2id

Node.js od wersji 24 udostępnia własne API do implementacji algorytmu argon2id, ale nasza aplikacja jest obecnie rozwijana na Node.js 22. W przyszłości będziemy mogli zmigrować aplikację do nowszej wersji, ale obecnie możemy użyć zewnętrznej implementacji przy pomocy pakietu argon2, zainstalujmy go.

npm install argon2

Moduł argon2 zapewnia stosunkowo wygodny interfejs, polecam sprawdzić dokumentację na GitHubie. Autor automatycznie dodaje do każdego hashowanego hasła salt i zapisuje go w wynikowym stringu ze skrótem hasła, co uprości naszą implementację.

Jak wspomniałem wcześniej, jeszcze nie przerobiliśmy tematu asynchroniczności w JavaScript, a argon2 korzysta z tego konceptu. Omówimy sobie to w dużym skrócie za chwilę.

Generacja sekretu pepper

Jeżeli chcemy dodać do naszego pepper, dobrze by było przechować go poza bazą danych. Użyjemy znów zmiennych środowiskowych i zmodyfikujemy nasz generator pliku środowiskowego.

> utils/generate_env.js
1
2
3
4
5
#!/usr/bin/env bash

echo PORT=8000
echo SECRET=\"$(cat /dev/random | tr -cd "[:graph:]" | head -c64)\"
echo PEPPER=\"$(cat /dev/random | tr -cd "[:xdigit:]" | head -c64)\"

Ponieważ implementacja argon2 pozwala na użycie typu Buffer w którym możemy przechowywać dowolne dane binarne, wygenerujemy sobie ciąg 64 znaków heksadecymalnych i przekształcimy go później na 32 bajtowy bufor danych.

Implementacja modelu

Kiedy wszystko już jest przygotowane możemy przejść przez implementację samego modelu:

> models/user.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { DatabaseSync } from "node:sqlite";
import argon2 from "argon2";

const PEPPER = process.env.PEPPER;
if (PEPPER == null) {
  console.error(
    `PEPPER environment variable missing.
     Please create an env file or provide SECRET via environment variables.`,
  );
  process.exit(1);
}

const HASH_PARAMS = {
  secret: Buffer.from(PEPPER, "hex"),
};

const db_path = "./db.sqlite";
const db = new DatabaseSync(db_path);

db.exec(`
  CREATE TABLE IF NOT EXISTS fc_users (
    id              INTEGER PRIMARY KEY,
    username        TEXT UNIQUE,
    passhash        TEXT,
    created_at      INTEGER
  ) STRICT;
  `);

const db_ops = {
  create_user: db.prepare(
    "INSERT INTO fc_users (username, passhash, created_at) VALUES (?, ?, ?) RETURNING id;",
  ),
  get_user: db.prepare(
    "SELECT id, username, created_at FROM fc_users WHERE id = ?;",
  ),
  find_by_username: db.prepare(
    "SELECT id, username, created_at FROM fc_users WHERE username = ?;",
  ),
  get_auth_data: db.prepare(
    "SELECT id, passhash FROM fc_users WHERE username = ?;",
  ),
};

export async function createUser(username, password) {
  let existing_user = db_ops.find_by_username.get(username);

  if (existing_user != null) {
    return null;
  }
  let createdAt = Date.now();
  let passhash = await argon2.hash(password, HASH_PARAMS);

  return db_ops.create_user.get(username, passhash, createdAt);
}

export async function validatePassword(username, password) {
  let auth_data = db_ops.get_auth_data.get(username);
  if (auth_data != null) {
    if (await argon2.verify(auth_data.passhash, password, HASH_PARAMS)) {
      return auth_data.id;
    }
  }
  return null;
}

export function getUser(user_id) {
  return db_ops.get_user.get(user_id);
}

export default {
  createUser,
  validatePassword,
  getUser,
};

W zaznaczonych liniach pojawiają się słowa kluczowe async i await.

Słowo await sprawia, że wykonanie funkcji zostanie w tym miejscu zawieszone aż do czasu uzyskania wyniku wywołania po prawej stronie. W międzyczasie środowisko node może się zająć innymi rzeczami, np. obsługą innych żądań do serwera. Aby móc użyć słowa kluczowego await w funkcji, musi ona być stworzona jako funkcja asynchroniczna, czyli przy jej deklaracji musi pojawić się async function zamiast zwykłego function.

Poza tym szczegółem, na podstawie dotychczasowej zdobytej wiedzy, powinnxś być w stanie zrozumieć resztę implementacji naszego nowego modelu.

Logowanie użytkownika

Zanim zaczniemy implementować cokolwiek dalej, zastanówmy się, czego będziemy potrzebowali? Jakie minimum funkcjonalności potrzebujemy zaimplementować, żeby mówić o kompletnym systemie logowania?

Sporo tego jak na “prostą funkcjonalność”, którą można zamknąć w dwóch słowach, tj. logowanie użytkownika. Niech to będzie przestrogą na przyszłość, że czasami dodanie jednej małej rzeczy do projektu może się okazać dużo większym nakładem pracy. A nawet nie mówimy jeszcze o powiązaniu tego z prawami dostępu do czegokolwiek - o tym na następnej lekcji. A teraz czas się wziąć znów do pracy.

Kontroler logowania

Stworzymy sobie nowy katalog na moduły, które będziemy nazywać kontrolerami. Nazwa kontroler, podobnie jak model, nawiązuje do starego, ale nadal bardzo popularnego wzorca architektury Model-View-Controller. O ile modele odpowiadają za przechowywanie danych i manipulowania nimi, o tyle kontrolery służą do interpretowania komend użytkownika i przekształcania ich na operacje na modelach. W przypadku aplikacji internetowej komendy od użytkownika przychodzą w formie żądań HTTP. Widoki powinny być odpowiedzialne za prezentację danych użytkownikowi, co w przypadku backendowych aplikacji webowych można sprowadzić do generowania dokumentów w formacie HTML, JSON lub dowolnym innym.

W każdym razie stworzymy moduł obsługujący żądania dotyczące autentykacji użytkownika i wykorzystujący nasze modele sesji oraz użytkownika do zapisania odpowiednich informacji w bazie danych.

> controllers/auth.js
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
import { createSession, deleteSession } from "./../models/session.js";
import { createUser, validatePassword } from "./../models/user.js";

export function signup_get(req, res) {
  let form = {
    data: {},
    fields: signup_form_fields,
    errors: {},
    action: "/auth/signup",
    method: "POST",
  };
  res.render("auth_signup", { title: "Rejestracja", form });
}

export async function signup_post(req, res) {
  let form = {
    data: getFormData(req, signup_form_fields),
    fields: signup_form_fields,
    action: "/auth/signup",
    method: "POST",
  };
  form.errors = validateForm(form.data, form.fields);

  if (Object.entries(form.errors).length == 0) {
    let user = await createUser(form.data["username"], form.data["password"]);
    if (user != null) {
      createSession(user.id, res);
      res.redirect("/");
      return;
    } else {
      form.errors["username"] = "Użytkownik o podanej nazwie już istnieje";
    }
  }

  res.render("auth_signup", { title: "Rejestracja", form });
}

export function login_get(req, res) {
  let form = {
    data: {},
    fields: login_form_fields,
    errors: {},
    action: "/auth/login",
    method: "POST",
  };
  res.render("auth_login", { title: "Logowanie", form });
}

export async function login_post(req, res) {
  let form = {
    data: getFormData(req, login_form_fields),
    fields: login_form_fields,
    action: "/auth/login",
    method: "POST",
  };
  form.errors = validateForm(form.data, form.fields);

  if (Object.entries(form.errors).length == 0) {
    let user_id = await validatePassword(
      form.data["username"],
      form.data["password"],
    );
    if (user_id == null) {
      form.errors["username"] = "Niepoprawna nazwa użytkownika lub hasło";
    } else {
      createSession(user_id, res);
      res.redirect("/");
      return;
    }
  }

  res.render("auth_login", { title: "Logowanie", form });
}

function logout(req, res) {
  if (res.locals.user != null) {
    deleteSession(res);
  }
  res.redirect("/");
}

export default {
  login_get,
  login_post,
  signup_get,
  signup_post,
  logout,
};

const login_form_fields = [
  {
    name: "username",
    display_name: "Nazwa użytkownika",
    type: "text",
    min_length: 3,
    max_length: 25,
    required: true,
  },
  {
    name: "password",
    display_name: "Hasło",
    type: "password",
    min_length: 8,
    required: true,
  },
];

const signup_form_fields = [
  {
    name: "username",
    display_name: "Nazwa użytkownika",
    type: "text",
    min_length: 3,
    max_length: 25,
    required: true,
  },
  {
    name: "password",
    display_name: "Hasło",
    type: "password",
    min_length: 8,
    required: true,
  },
  {
    name: "password_confirm",
    display_name: "Powtórz hasło",
    type: "password",
    min_length: 8,
    required: true,
    must_match: "password",
  },
];

function getFormData(req, fields) {
  const data = {};
  fields.forEach((field) => {
    data[field.name] = req.body[field.name];
  });
  return data;
}

function validateForm(data, fields) {
  const errors = {};
  fields.forEach((field) => {
    if (field.required && typeof data[field.name] !== "string") {
      errors[field.name] = "Pole jest wymagane";
      return;
    }
    if (
      field.min_length != null &&
      data[field.name].length < field.min_length
    ) {
      errors[field.name] =
        `Pole musi zawierać minimum ${field.min_length} znaków`;
    }
    if (
      field.max_length != null &&
      data[field.name].length > field.max_length
    ) {
      errors[field.name] =
        `Pole może zawierać maksymalnie ${field.max_length} znaków`;
    }
    if (
      field.must_match != null &&
      data[field.name] !== data[field.must_match]
    ) {
      errors[field.name] =
        `Zawartość musi się zgadzać z polem ${fields.find((f) => f.name === field.must_match).display_name}`;
    }
  });
  return errors;
}

Jak widać implementacja nie jest krótka, ale nie jest też bardzo skomplikowana. Zwróć uwagę na ponownie pojawiające się słowa kluczowe async i await. Skoro nasze funkcje z modelu użytkownika są asynchroniczne, to jeżeli chcemy z nich skorzystać, również musimy korzystać z tych samych mechanizmów. Dokładniej postaram się to omówić na jednej z następnych lekcji.

Przeanalizuj kod modułu i upewnij się, że rozumiesz jak działa.

W tym module postanowiłem podejść w nieco bardziej uporządkowany sposób do tematu formularza logowania i rejestracji, dzięki czemu będziemy mogli wygenerować sobie za chwilę formularz w sposób generyczy.

Generyczny formularz

Dzięki ustrukturyzowaniu informacji na temat formularza w podobny sposób, widoki obsługujące rejestrację i logowanie użytkownika mogą być mocno uproszczone:

> views/auth_login.ejs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<%- include("head.partial.ejs") %>

<main>
  <h1><%= title %></h1>
  <div>
    <%- include("forms/generic.ejs", form) %>
  </div>
  <span>Nie masz konta? <a href="/auth/signup">Zarejestruj się!</a></span>
</main>

<%- include("foot.partial.ejs") %>
> views/auth_signup.ejs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<%- include("head.partial.ejs") %>

<main>
  <h1><%= title %></h1>
  <div>
    <%- include("forms/generic.ejs", form) %>
  </div>
  <span>Masz już konto? <a href="/auth/login">Zaloguj się!</a></span>
</main>

<%- include("foot.partial.ejs") %>

To zazwyczaj oznacza, że skomplikowanie jest zaszyte gdzieś w niżej warstwie, w tym wypadku w pliku views/forms/generic.ejs:

> views/forms/generic.ejs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<form class="form" action="<%- action %>" method="<%- method %>">
  <% fields.forEach((field) => { %>
  <div class="form-row">
    <label for="field_<%- field.name %>"><%= field.display_name %></label>
    <div class="form-input">
      <input 
        type="<%- field.type %>"
        name="<%- field.name %>"
        id="field_<%- field.type %>"
        <% if (field.required) { %>
        required
        <% } %>
        <% if (field.min_length) { %>
        minlength="<%- field.min_length %>"
        <% } %>
        <% if (field.max_length) { %>
        maxlength="<%- field.max_length %>"
        <% } %>
        <% if (data[field.name]) { %>
        value="<%= data[field.name] %>"
        <% } %>
      />
      <% if (errors[field.name]) { %>
        <span class="form-error"><%= errors[field.name] %></span>
      <% } %>
    </div>
  </div>
  <% }) %>
  <div class="form-row">
    <label></label>
    <button class="submit-button"><%=  locals.submit_text || "Wyślij" %></button>
  </div>
</form>

Nie jest to może bardzo skomplikowane, głównie sprowadza się to do warunkowego dodawania atrybutów w zależności od tego, jakie dane zostały podane do formularza.

Podpięcie kontrolera do serwera

Aby spiąć to wszystko z resztą aplikacji musimy dodać odpowiednią konfigurację do naszego pliku index.js.

> index.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import express from "express";
import morgan from "morgan";
import cookieParser from "cookie-parser";

import flashcards from "./models/flashcards.js";
import settings from "./models/settings.js";
import session from "./models/session.js";
import auth from "./controllers/auth.js";

const port = process.env.PORT || 8000;
const LAST_VIEWED_COOKIE = "__Host-fisz-last-viewed";
const ONE_DAY = 24 * 60 * 60 * 1000;
const ONE_MONTH = 30 * ONE_DAY;
const SECRET = process.env.SECRET;

if (SECRET == null) {
  console.error(
    `SECRET environment variable missing.
     Please create an env file or provide SECRET via environment variables.`,
  );
  process.exit(1);
}

const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));
app.use(express.urlencoded());
app.use(morgan("dev"));
app.use(cookieParser(SECRET));

app.use(settings.settingsHandler);
app.use(session.sessionHandler);

const settingsRouter = express.Router();
settingsRouter.use("/toggle-theme", settings.themeToggle);
settingsRouter.use("/accept-cookies", settings.acceptCookies);
settingsRouter.use("/decline-cookies", settings.declineCookies);
settingsRouter.use("/manage-cookies", settings.manageCookies);
app.use("/settings", settingsRouter);

const authRouter = express.Router();
authRouter.get("/signup", auth.signup_get);
authRouter.post("/signup", auth.signup_post);
authRouter.get("/login", auth.login_get);
authRouter.post("/login", auth.login_post);
authRouter.get("/logout", auth.logout);
app.use("/auth", authRouter);

app.get("/", (req, res) => {
    // ...

Pozostałe wymagane zmiany

Wcześniej wypunktowaliśmy sobie, czego potrzebujemy aby nasz system logowania uznać za działający. Z listy pozostało nam:

Kwestię analizy zmian w zakresie CSS i dodania odpowiednich linków nawigacyjnych pozostawiam jako pracę własną. Kod znajdziecie w repozytorium z kodem do tych zajęć w podfolderze dedykowanym tej lekcji.