kleindan.dev

Przykładowa implementacja systemów kontroli dostępu

W tej lekcji, korzystając z naszej aplikacji fiszkowej, zaproponuję przykładową implementację dwóch z trzech ostatnio omówionych metod kontroli dostępu. Przed nami znacznie mniej teorii i znacznie więcej praktyki.

Wideo towarzyszące

Dostęp dla zalogowanych użytkowników

Implementację ograniczenia dostępu do części aplikacji wyłącznie dla zalogowanych użytkowników mogłeś przeanalizować samodzielnie podczas ostatniej lekcji. Jej centralnym elementem jest funkcja middleware login_required.

> controllers/auth.js
 99
100
101
102
103
104
105
function login_required(req, res, next) {
  if (res.locals.user == null) {
    res.redirect(`/auth/login?next=${encodeURIComponent(req.path)}`);
    return;
  }
  next();
}

Jak pamiętamy z poprzednich lekcji, funkcje middleware mogą albo obsłużyć żądanie i zakończyć jego przetwarzanie, albo przekazać je do kolejnych handlerów w łańcuchu. Dlatego żądania o pliki statyczne nigdy nie trafiają do naszych logów morgan - ponieważ middleware express.static obsługuje te żądania w całości i nie przekazuje ich dalej w dół łańcucha kolejnych handlerów.

Powyższe middleware login_required działa trochę podobnie. W linii 100 sprawdza ono, czy do obecnego żądania jest przypięty jakiś użytkownik i jeżeli tak, w linii 104 daje znać Express, że żądanie powinno być dalej przetwarzane, tym samym dając szansę handlerom odpowiedniej ścieżki na przygotowanie odpowiedzi w formie strony HTML. Jeżeli do obecnego żądania nie jest przypisany żaden użytkownik systemu, middleware odsyła do przeglądarki przekierowanie do ścieżki zawierającej formularz logowania. Dodatkowo do ścieżki jest dodawany parametr query next, ale o nim za moment.

Jeżeli nie jesteś pewien, skąd bierze się obiekt przedstawiający użytkownika systemu w zmiennej res.locals.user, to przypominam o middleware odpowiedzialnym za obsługę sesji:

> models/session.js
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
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.locals.user = session.user_id != null ? getUser(session.user_id) : null;

    res.cookie(SESSION_COOKIE, res.locals.session.id.toString(), {
      maxAge: ONE_WEEK,
      httpOnly: true,
      secure: true,
    });
  } else {
    session = createSession(null, res);
  }

  // ...

Poza tym centralnym elementem kontroli dostępu, najwięcej pracy wymagało dodanie funkcjonalności przekierowywania z ekranu logowania z powrotem do strony, do której użytkownik próbował się dostać. Jest to zrealizowane w handlerze obsługującym logowanie użytkownika poprzez parametr query next będący częścią URI:

> controllers/auth.js
 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
export function login_get(req, res) {
  let nextUrl = req.query.next;
  let form = {
    data: {},
    fields: login_form_fields,
    errors: {},
    action: "/auth/login",
    method: "POST",
  };
  if (nextUrl) {
    form.action = `/auth/login?next=${encodeURIComponent(nextUrl)}`;
  }
  res.render("auth_login", { title: "Logowanie", form, nextUrl });
}

export async function login_post(req, res) {
  let nextUrl = req.query.next;
  let form = {
    data: getFormData(req, login_form_fields),
    fields: login_form_fields,
    action: "/auth/login",
    method: "POST",
  };
  if (nextUrl) {
    form.action = `/auth/login?next=${encodeURIComponent(nextUrl)}`;
  }
  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(nextUrl || "/");
      return;
    }
  }

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

Podkreślone fragmenty pliku są odpowiedzialne za przekierowanie użytkownika pod właściwy adres po zalogowaniu. Jeżeli sobie przypominasz, to middleware login_required ustawiało parametr query next na ścieżkę obecnie przetwarzanego żądania.

Na koniec musieliśmy dopisać middleware login_required przed handlerem dla wszystkich ścieżek, do których chcemy dopuścić tylko zalogowanych użytkowników. Przykładowe dodanie tego middleware wygląda tak:

> controllers/auth.js
131
132
133
134
135
app.get("/new_cardset", auth.login_required, (req, res) => {
  res.render("cardset_new", {
    title: "Nowy zestaw",
  });
});

ReBAC: edycja własnych zasobów

Aby dodać zależność pomiędzy zestawem fiszek a użytkownikiem musimy zmodyfikować jakoś nasze tabele w bazie danych. Ponieważ w tym momencie mówimy o relacji jeden-do-wielu (każdy zestaw fiszek ma autora, użytkownik może być autorem zera lub większej liczby zestawów), najsensowniejszym rozwiązaniem będzie zmiana sposobu przechowywania zestawów fiszek w bazie danych i dodanie do nich obowiązkowej relacji do użytkownika jako autora.

Zmiana tego rodzaju jest zmianą psującą działanie aplikacji. Samo dodanie dodatkowej kolumny do zapytań SQL spowoduje, że baza zacznie nam zgłaszać błędy. Na naszym etapie rozwoju aplikacji nie ma to znaczenia, ponieważ nie mamy nigdzie działających systemów produkcyjnych ani nawet długo działających systemów testowych. Możemy po prostu skasować starą bazę danych i wygenerować nowe dane testowe. W innym przypadku musielibyśmy przygotować tzw. skrypty migracyjne, które modyfikowałyby istniejące bazy danych tak, aby działały one z nową wersją naszej aplikacji.

> models/flashcards.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
import { DatabaseSync } from "node:sqlite";

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

db.exec(
  `CREATE TABLE IF NOT EXISTS fc_cardsets (
    cardset_id    INTEGER PRIMARY KEY,
    slug          TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL,
    author_id     INTEGER NOT NULL REFERENCES fc_users(user_id) ON DELETE NO ACTION
  ) STRICT;
  CREATE TABLE IF NOT EXISTS fc_cards (
    card_id       INTEGER PRIMARY KEY,
    cardset_id    INTEGER NOT NULL REFERENCES fc_cardsets(cardset_id) ON DELETE NO ACTION,
    front         TEXT NOT NULL,
    back          TEXT NOT NULL
  ) STRICT;`,
);

const db_ops = {
  insert_cardset: db.prepare(
    `INSERT INTO fc_cardsets (slug, name, author_id)
     VALUES (?, ?, ?) RETURNING cardset_id as id, slug, name;`,
  ),
  update_cardset_by_slug: db.prepare(
    `UPDATE fc_cardsets SET slug = $new_slug, name = $new_name 
      WHERE slug = $slug RETURNING cardset_id AS id, slug, name, author_id;`,
  ),
  insert_card_by_cardset_slug: db.prepare(
    `INSERT INTO fc_cards (cardset_id, front, back) VALUES (
      (SELECT cardset_id FROM fc_cardsets WHERE slug = ?),
      ?, 
      ?
    ) 
    RETURNING card_id AS id, front, back;`,
  ),
  get_cardset_summaries: db.prepare(
    "SELECT slug, name, author_id FROM fc_cardsets;",
  ),
  get_cardset_summary_by_cardset_id: db.prepare(
    "SELECT slug, name, author_id FROM fc_cardsets WHERE cardset_id = ?;",
  ),
  get_cardset_by_slug: db.prepare(
    "SELECT cardset_id AS id, slug, name, author_id FROM fc_cardsets WHERE slug = ?;",
  ),
  get_card_by_id: db.prepare(
    "SELECT card_id AS id, front, back FROM fc_cards WHERE card_id = ?;",
  ),
  update_card_by_id: db.prepare(
    "UPDATE fc_cards SET front = ?, back = ? WHERE card_id = ? RETURNING card_id, front, back;",
  ),
  delete_card_by_id: db.prepare("DELETE FROM fc_cards WHERE card_id = ?;"),
  get_cards_by_cardset_id: db.prepare(
    "SELECT card_id AS id, front, back FROM fc_cards WHERE cardset_id = ?;",
  ),
};

// ...

W linii 11 wprowadzamy nowe pole przechowujące identyfikator użytkownika będącego autorem. Większość wynikających z tego zmian sprowadza się do dodania tej informacji do przygotowanych zapytań do bazy danych w obiekcie db_ops tak, żeby informacja o autorze trafiła do reszty aplikacji.

Ponieważ relacja do użytkownika jest wymagana, obowiązkowo musi się także zmienić sygnatura funkcji dodającej nowy zestaw fiszek:

> models/flashcards.js
111
112
113
export function addCardset(slug, name, author) {
  return db_ops.insert_cardset.get(slug, name, author.id);
}

Dygresja: refactoring modelu flashcards.js

Jeżeli uważnie przejrzałxś zmiany w kodzie podczas ostatniej lekcji, to zauważyłxś, że w modelu flashcards.js zmieniło się sporo, ale były to głównie korekty nazewnictwa. Dawne “kategorie” stały się “zestawami fiszek”, a identyfikatory zostały nazwane bardziej adekwatnie do ich funkcji - identyfikator tekstowy jest teraz nazwany slug. Slug jest angielskim terminem wywodzącym się z druku prasy i w kontekście aplikacji internetowych oznacza krótki kawałek tekstu identyfikujący dany artykuł. Slug w praktyce to zazwyczaj fragment tytułu zakodowany w sposób zdatny do bycia częścią ścieżki HTTP - zupełnie jak u nas.

Tego typu zmiany, poprawki w kodzie niezmieniające funkcjonalności aplikacji, ale mające na celu poprawę czytelności, logiczną reorganizację czy poprawę wydajności nazywamy refactoringiem. Refactoring jest potrzebnym procesem w rozwoju aplikacji, ale wymaga trochę pracy, szczególnie jeżeli wpływa na wiele plików w aplikacji. Z drugiej strony poświęcamy czas na zmiany, które intencjonalnie nie mają żadnego wpływu na działanie aplikacji - z punktu widzenia klienta płacącego za rozwój aplikacji, marnujemy jego pieniądze. Z jednej strony rozumiem taki punkt widzenia, można utknąć w wiecznej pętli refactoringu w poszukiwaniu “idealnego” kodu, podczas gdy aplikacja pod względem funkcjonalności stoi w miejscu. Podjęcie dobrej decyzji o refactoringu wymaga odrobiny wprawy - w tym wypadku zdecydowałem się na podjęcie wysiłku, bo rozróżniania identyfikatora tekstowego od identyfikatora numerycznego już mnie momentami myliło. Najlepsza rada jaką mogę dać w temacie, kiedy się brać za refactoring, to ufać swojej intuicji.

Naprawienie skryptu generującego bazę danych

Ponieważ teraz stworzenie zestawu fiszek wymaga konta użytkownika, musimy zmodyfikować skrypt wypełniający bazę danymi testowymi tak, aby stworzył testowe konta użytkowników zanim doda nasze przykładowe fiszki.

> utils/populate_db.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
import user from "../models/user.js";
import flashcards from "../models/flashcards.js";

const cardsets = {
  "j-angielski-food": {
    name: "j. angielski - food",
    cards: [
      { front: "truskawka", back: "strawberry" },
      { front: "gałka muszkatołowa", back: "nutmeg" },
      { front: "jabłko", back: "apple" },
      { front: "karczoch", back: "artichoke" },
      { front: "cielęcina", back: "veal" },
    ],
  },
  "stolice-europejskie": {
    name: "stolice europejskie",
    cards: [
      { front: "Holandia", back: "Amsterdam" },
      { front: "Włochy", back: "Rzym" },
      { front: "Niemcy", back: "Berlin" },
      { front: "Węgry", back: "Budapeszt" },
      { front: "Rumunia", back: "Bukareszt" },
    ],
  },
};

console.log("Populating db...");

// TODO(kleindan) prompt for admin password in the future
await user.createUser("admin", "changeme");
let student = await user.createUser("student", "changeme");

Object.entries(cardsets).map(([slug, data]) => {
  let category = flashcards.addCardset(slug, data.name, student);
  for (let card of data.cards) {
    let c = flashcards.addCard(category.slug, card);
  }
});

console.log("Done!");

Umożliwienie edycji zestawu fiszek wyłącznie autorowi

W celu weryfikacji uprawnień do edycji zestawu fiszek stworzymy funkcję, która zwróci nam informację, czy podany użytkownik może edytować wybrany zestaw fiszek. Aby powiązać funkcję z obiektem dodamy ją jako pole obiektu reprezentującego zestaw fiszek:

> models/flashcards.js
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
export function getCardset(slug) {
  let cardset = db_ops.get_cardset_by_slug.get(slug);
  if (cardset != null) {
    cardset.cards = db_ops.get_cards_by_cardset_id.all(cardset.id);
    cardset.editableBy = cardsetEditableBy;

    return cardset;
  }

  return null;
}

function cardsetEditableBy(user) {
  return user != null && this.author_id === user.id;
}

Dodatkowo możemy chcieć też wyeksportować z modelu funkcję, która sprawdzi możliwość edycji zestawu fiszek na podstawie slug tego zestawu. W ten sposób możemy w innych częściach aplikacji sprawdzić możliwość edycji bez pobierania całości zestawu kart łącznie z powiązanymi kartami. Mała i być może zbędna optymalizacja, ale nikt nam tego nie zabroni:

> models/flashcards.js
160
161
162
163
164
165
export function canEdit(cardsetSlug, user) {
  let cardset = db_ops.get_cardset_by_slug.get(cardsetSlug);
  cardset.editableBy = cardsetEditableBy;

  return cardset.editableBy(user);
}

Dlaczego korzystamy tu znów z funkcji cardsetEditableBy zamiast przepisać na nowo jej logikę? Czy to nie byłoby bardziej efektywne niż wywoływanie funkcji na obiekcie? Tak! Z drugiej strony dzięki użyciu dokładnie tej samej funkcji upewniam się, że logika jest zdefiniowana tylko w jednym miejscu. Jeżeli kiedykolwiek będę chciał tę logikę zmienić, nie będę musiał pamiętać o zmienianiu jej we wszystkich miejscach, gdzie została zdefiniowana.

Obsługa uprawnień po stronie serwera

Teraz kiedy mamy możliwość potwierdzenia, czy obecny użytkownik może edytować zestawy fiszek, możemy dodać odpowiednią logikę w naszych handlerach żądań HTTP:

> index.js
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
app.get("/edit/:cardset_slug", auth.login_required, (req, res) => {
  const cardset_slug = req.params.cardset_slug;
  const errors = [];
  var cardset = flashcards.getCardset(cardset_slug);
  if (cardset != null) {
    if (!cardset.editableBy(res.locals.user)) {
      res.status(401);
      res.redirect("/");
    } else {
      res.render("cardset_edit", {
        errors,
        title: "Edycja zestawu",
        cardset,
      });
    }
  } else {
    res.sendStatus(404);
  }
});

app.post("/edit/:cardset_slug", auth.login_required, (req, res) => {
  const cardset_slug = req.params.cardset_slug;
  if (flashcards.hasCardset(cardset_slug)) {
    if (!flashcards.canEdit(cardset_slug, res.locals.user)) {
      res.status(401);
      res.redirect("/");
    } else {
      const cardset_name = req.body.name;
      var new_cardset_slug = null;
      const errors = flashcards.validateCardsetName(cardset_name);
      if (errors.length == 0) {
        new_cardset_slug = flashcards.generateCardsetSlug(cardset_name);
        if (
          new_cardset_slug !== cardset_slug &&
          flashcards.hasCardset(new_cardset_slug)
        ) {
          errors.push("Cardset id is already taken");
        }
      }
      if (errors.length == 0) {
        const cardset = flashcards.updateCardset(
          cardset_slug,
          new_cardset_slug,
          cardset_name,
        );
        if (cardset != null) {
          // cardset id may have changed due to name change
          res.redirect("/view/" + cardset.slug);
        } else {
          // This should never happen
          res.write("Unexpected error while updating cardset");
          res.sendStatus(500);
        }
      } else {
        const cardset = flashcards.getCardset(cardset_slug);
        res.render("cardset_edit", {
          errors,
          title: "Edycja zestawu",
          cardset,
        });
      }
    }
  } else {
    res.sendStatus(404);
  }
});

Nie są to wszystkie użycia nowych funkcji, jeżeli chcemy obwarować dostęp do edycji zestawów dla autorów, ale pokazują one podstawowy mechanizm.

Zwróć uwagę, że w przypadku negatywnej weryfikacji praw do edycji odpowiadamy statusem HTTP 401 Unauthorized.

Ukrywanie linków do edycji dla nieuprawnionych użytkowników

Ponieważ w widokach mamy dostęp do wszystkich obiektów przekazanych do funkcji render oraz tych dodanych wcześniej jako właściwości do obiektu res.locals, w naszych widokach możemy bezpośrednio odnosić się do zmiennej user przedstawiającej obecnie zalogowanego użytkownika. Jeżeli do widoku przekażemy też obiekt reprezentujący zestaw fiszek wraz z przypiętymi do niego metodami, to możemy z tych metod korzystać bezpośrednio w widoku. W widoku dla pojedynczego zestawu fiszek wykorzystanie tych technik może wyglądać w ten sposób:

> views/cardset.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
<%- include("head.partial.ejs") %>

<main>
  <h1><%= title %></h1>
  <div class="cardset-author">Autor: <%= cardset.author.username %></div>
  <% if (cardset.editableBy(user)) { %>
    <a href="/edit/<%= cardset.slug %>" class="action-link">Edytuj zestaw</a>
  <% } %>
  <% if (cardset.cards && cardset.cards.length > 0) { %>
    <ul id="flashcards">
      <% cardset.cards.forEach(function(card){ %>
      <li>
        <%= card.front %> - <%= card.back %>
      </li>
      <% }) %>
    </ul>
  <% } else { %>
    <p>W tym zestawie nie ma żadnych fiszek. Dodaj nowe poniżej.</p>
  <% } %>
  <% if (cardset.editableBy(user)) { %>
  <div>
    <h3>Nowa fiszka</h3>
    <%- include("forms/new_card.ejs") %>
  </div>
  <% } %>
</main>

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

Celem organizacji kodu w dotychczasowy sposób jest sprawienie, że reszta kodu będzie czytelna. Mam nadzieję, że patrząc na kod widoku zgodzisz się, że udało się cel zrealizować.

ABAC: uprawnienia administratora

Jeżeli chcemy nadać specjalne uprawnienia jakiemuś użytkownikowi, musimy gdzieś umieścić informację na ten temat. To oznacza kolejną modyfikację bazy danych. Zanim jednak do tego przejdziemy, zastanówmy się, jakie mamy możliwości:

Tutaj zaimplementujemy drugie rozwiązanie, ale możesz potraktować tę lekcję jako okazję do zaimplementowania pierwszego podejścia i porównania wyników pod względem poziomu skomplikowania i ilości wymaganej pracy.

Arbitralne właściwości użytkownika

Jeżeli chcemy przypisać do użytkownika dowolne atrybuty, możemy wykorzystać fakt, że programujemy w języku JavaScript i mamy wbudowane, natywne wsparcie dla notacji JSON. Dzięki temu możemy do tabeli użytkowników dodać dodatkowe pole tekstowe, które będzie przechowywać zserializowany (tj. przetworzony na ciąg znaków) obiekt JSON, który będzie zawierać rzeczone atrybuty. Nie będzie to bardzo efektywne pod względem wydajności, ale na pewno będzie elastyczne.

Przyjmując powyższe założenia, jeżeli postanowilibyśmy dodać dla użytkownika admin właściwość “is_admin=true” w bazie danych byłoby to przechowane jako “{"is_admin":true}”.

Wyciągając informacje z bazy danych musimy przetworzyć przechowany string na obiekt JavaScript, do czego służy funkcja JSON.parse. Moglibyśmy tak uzyskany obiekt umieścić w obiekcie użytkownika w polu attributes, wtedy obiekt reprezentujący użytkownika w aplikacji miałby taką postać:

{
    "id": 1,
    "username": "admin",
    "created_at": 123456789,
    "attributes": {
        "is_admin": true
    }
}

Co nie jest złą implementacją, ale odwoływanie się do właściwości obiektu przez pole o nazwie “właściwości” wydaje się zapętloną logiką. Możemy chcieć zatem przetworzyć dane wyciągnięte z bazy danych tak, aby obiekt reprezentujący użytkownika miał bardziej płaską strukturę:

{
    "id": 1,
    "username": "admin",
    "created_at": 123456789,
    "is_admin": true
}

Jeżeli się na to zdecydujemy, warto jednak obwarować dopuszczalne nazwy dodawanych do użytkownika atrybutów, tak aby nie można było nadpisać tych, które już są w bazie danych, np. id czy username.

Biorąc to wszystko pod uwagę przeanalizuj poniższy kod skupiając się na podkreślonych fragmentach:

> 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
 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
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 (
    user_id         INTEGER PRIMARY KEY,
    username        TEXT UNIQUE,
    passhash        TEXT,
    attributes      TEXT DEFAULT NULL,
    created_at      INTEGER
  ) STRICT;
  `);

const db_ops = {
  create_user: db.prepare(
    `INSERT INTO fc_users (username, passhash, created_at) 
     VALUES (?, ?, ?) RETURNING user_id AS id;`,
  ),
  get_user: db.prepare(
    `SELECT user_id AS id, username, attributes, created_at 
     FROM fc_users WHERE id = ?;`,
  ),
  find_by_username: db.prepare(
    `SELECT user_id AS id, username, attributes, created_at 
    FROM fc_users WHERE username = ?;`,
  ),
  get_auth_data: db.prepare(
    `SELECT user_id AS id, passhash FROM fc_users WHERE username = ?;`,
  ),
  get_attributes: db.prepare(
    `SELECT attributes FROM fc_users WHERE user_id = ?;`,
  ),
  update_attributes: db.prepare(
    `UPDATE fc_users SET attributes = ? WHERE user_id = ?;`,
  ),
};

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(userId) {
  let { id, username, attributes, created_at } = db_ops.get_user.get(userId);
  return {
    id,
    username,
    created_at,
    ...JSON.parse(attributes),
  };
}

const forbiddenAttributeNames = new Set([
  "username",
  "id",
  "user_id",
  "passhash",
  "attributes",
  "created_at",
]);
const allowedAttributeValueTypes = new Set(["string", "boolean", "number"]);
const attributeNameRegex = /^[a-z_]+$/;

export function addAttribute(userId, name, value) {
  if (typeof name != "string") {
    return "attribute name must be a string";
  }
  if (forbiddenAttributeNames.has(name)) {
    return "forbidden attribute name";
  }
  if (!name.match(attributeNameRegex)) {
    return "attribute name should consist of lowercase letters and underscores";
  }
  if (!allowedAttributeValueTypes.has(typeof value)) {
    return "only simple value types are allowed";
  }

  let queryResult = db_ops.get_attributes.get(userId);
  let attributes =
    queryResult.attributes != null ? JSON.parse(queryResult.attributes) : {};
  attributes[name] = value;
  db_ops.update_attributes.run(JSON.stringify(attributes), userId);

  return null;
}

export default {
  createUser,
  validatePassword,
  getUser,
  addAttribute,
};

Funkcja addAttribute najpierw upewnia się, że podane argumenty są odpowiednich typów i że nazwa atrybutu nie widnieje na liście zakazanych nazw. Jeżeli argumenty przejdą poprawnie sprawdzenia, pobieramy z bazy danych string reprezentujący aktualne atrybuty powiązane z użytkownikiem i przetwarzamy go na obiekt JSON. Jeżeli użytkownik nie ma żadnych przypisanych atrybutów, w bazie danych będzie wartość null, więc w linii 113 obsługujemy taki przypadek tworząc nowy, pusty obiekt. Następnie do obiektu dodajemy podaną jako argument wartość i zapisujemy z powrotem atrybuty w bazie danych.

W przypadku tej funkcji przyjęliśmy konwencję, że funkcja zwróci null w przypadku sukcesu, albo string z treścią błędu w przypadku niepowodzenia.

W celu przeszukania zestawu zakazanych nazw lub dozwolonych typów korzystamy z klasy Set dostępnej w JavaScript, która jest implementacją zbiorów. Wybrałem tę klasę, ponieważ pozwala na potencjalnie szybsze znalezienie poszukiwanych elementów niż tablica (Array) i nie przechowuje dodatkowych danych powiązanych z kluczami, tak jak obiekt (Object). Jeżeli jesteś zainteresowanx, polecam przeczytanie dokumentacji klasy Set.

To co powoduje, że reszta aplikacji otrzymuje płaski obiekt reprezentujący użytkownika wraz z przypisanymi mu atrybutami to implementacja funkcji getUser, w której najpierw pobieramy z bazy danych informacje o użytkowniku i przypisujemy je do osobnych zmiennych, których nazwy odpowiadają nazwom właściwości obiektu zwróconego z bazy danych. Następnie używając operatora spread tworzymy nowy obiekt zawierający dodatkowe właściwości.

Implementacja może na pierwszy rzut oka być nieczytelna. To kwestia obycia ze składnią JavaScript i zrozumienia działania użytych operatorów. Przeprowadźmy symulację działania funkcji krok po kroku używając REPL node:

$ node
Welcome to Node.js v23.11.0.
Type ".help" for more information.
> let user = {
... "id": 1,
... "username": "admin",
... "created_at": 123456789,
... "attributes": '{ "is_admin": true }',
... };
undefined
> user
{
  id: 1,
  username: 'admin',
  created_at: 123456789,
  attributes: '{ "is_admin": true }'
}
> let {id, username, created_at, attributes} = user;
undefined
> id
1
> username
'admin'
> attributes
'{ "is_admin": true }'
> let output = {};
undefined
> let parsed_attributes = JSON.parse(attributes);
undefined
> parsed_attributes
{ is_admin: true }
> output = {
... id,
... username,
... created_at,
... ...JSON.parse(attributes),
... };
{ id: 1, username: 'admin', created_at: 123456789, is_admin: true }
>

Zaraz, zaraz. Ale co się stanie, jeżeli użytkownik nie ma przypisanych żadnych atrybutów? W bazie danych w polu attributes będzie przechowana wartość null. Prześledźmy, jak zachowa się wtedy JavaScript krok po kroku:

$ node
Welcome to Node.js v23.11.0.
Type ".help" for more information.
> let user = { id: 2, username: "bob", created_at: 2345678901, attributes: null };
undefined
> user
{ id: 2, username: 'bob', created_at: 2345678901, attributes: null }
> let {id, username, created_at, attributes } = user;
undefined
> attributes
null
> let parsed_attributes = JSON.parse(attributes)
undefined
> parsed_attributes
null
> let output = {};
undefined
> output = {
... id,
... username,
... created_at,
... ...parsed_attributes
... }
{ id: 2, username: 'bob', created_at: 2345678901 }
> JSON.parse(null)
null
> { id, ...null }
{ id: 2 }
>

Program zadziała tak jak się tego spodziewamy, ponieważ funkcja JSON.parse dla parametru null zwróci również null, a operator spread dla wartości null nie dodaje żadnych właściwości do tworzonego obiektu.

Uprawnienia administratora

Po zaimplementowaniu dodawania do kont użytkowników arbitralnych właściwości możemy teraz odpowiednio oznaczyć nasze konto administratora:

> utils/populate_db.js
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
console.log("Populating db...");

// TODO(kleindan) prompt for admin password in the future
let admin = await user.createUser("admin", "changeme");
let errMsg = user.addAttribute(admin.id, "is_admin", true);
if (errMsg) {
  console.error(errMsg);
}

let student = await user.createUser("student", "changeme");

Object.entries(cardsets).map(([slug, data]) => {
  let category = flashcards.addCardset(slug, data.name, student);
  for (let card of data.cards) {
    let c = flashcards.addCard(category.slug, card);
  }
});

console.log("Done!");

Ostatnim krokiem jest modyfikacja funkcji pozwalającej na edytowanie zestawów fiszek. Dzięki temu, że dotychczasowe zmiany dodają do odpowiedniego konta atrybut, który jest potem reprezentowany przez właściwość na obiekcie reprezentującym użytkownika, implementacja dodania uprawnień jest trywialna:

> models/flashcards.js
91
92
93
function cardsetEditableBy(user) {
  return user != null && (this.author_id === user.id || user.is_admin);
}

Teraz możemy spróbować zalogować się na konto administratora i sprawdzić, czy wszystko działa tak, jak się tego spodziewaliśmy.

Podsumowanie

Przeszliśmy przez implementację kilku rodzajów systemów kontroli dostępu. Na tym etapie rozwoju aplikacji te systemy nie są może proste, ale nadal możliwe do prześledzenia i zrozumienia. Niestety wraz z rozwojem skomplikowania całego systemu, pilnowanie spójności systemów kontroli dostępu staje się trudniejsze. Właśnie dlatego “Broken Access Control” jest na szczycie listy zagrożeń aplikacji internetowych według OWASP TOP 10 z 2025 roku.

Niestety nie zapowiada się, żeby pojawiła się jakaś droga na skróty jeżeli chodzi o zabezpieczanie systemów informatycznych. Dlatego warto poświęcić czas na nauczenie się technik związanych z bezpieczeństwem, nabranie w nich biegłości i stałe udoskonalanie się w tym zakresie.

Zadania praktyczne

Stwórz nową “rolę” moderatora, który będzie miał uprawnienia do edycji zestawów fiszek. Następnie dodaj w ramach skryptu wypełniającego bazę danych stwórz konto moderatora bez uprawnień administratorskich. Na koniec zastanów się, czy pozostawić uprawnienia do edycji zestawów fiszek dla administratora, czy lepiej zostawić je wyłącznie moderatorom, ale nadać administratorowi również “rolę” moderatora. Co przemawia za jednym bądź drugim rozwiązaniem?