Benutzer-Login mit bcrypt und dem b4um Generator

Benutzer-Login mit bcrypt und dem b4um Generator

In diesem Tutorial erweitern wir eine mit dem b4um Generator erstellte Rails-Anwendung um eine einfache Benutzeranmeldung mit bcrypt.
Dabei bleiben wir so weit wie möglich beim b4um Generator und verwenden die bereits vorhandenen Komponenten und CSS-Klassen.
Am Ende kann unsere Anwendung zwischen Besuchern und angemeldeten Benutzern unterscheiden:
  • Besucher können öffentliche Inhalte ansehen.
  • Angemeldete Benutzer können zusätzlich neue Artikel erstellen.
  • Nur angemeldete Benutzer können Artikel bearbeiten und löschen.
  • Direkte Aufrufe geschützter URLs werden ebenfalls abgefangen.
Dabei lernen wir gleichzeitig, wie has_secure_password, bcrypt, Rails Sessions und ein einfacher Zugriffsschutz zusammenspielen.

1. bcrypt über den b4um Generator installieren

Wir beginnen mit dem Installationsgenerator von b4um:
bin/rails generate b4um:install
Während der Installation erscheint unter anderem die Frage:
Install bcrypt for password support? (y/n)
Hier antworten wir:
y
Ist bcrypt bereits vorhanden, meldet der Generator beispielsweise:
bcrypt is already present in the Gemfile.
Die übrigen Fragen können unabhängig davon beantwortet werden.
In unserem bestehenden Testprojekt wollten wir beispielsweise weder Active Storage noch Hero, Footer oder Cookie Consent erneut installieren:
Install Active Storage for image attachments? (y/n) n
Add a hero section? (y/n) n
Add a footer? (y/n) n
Add cookie consent? (y/n) n
Da die Anwendung bereits eine Navigation und eine config/b4um.yml besitzt, haben wir diese bei auftretenden Konflikten ebenfalls nicht überschrieben.
Das ist besonders bei einer bestehenden b4um-Anwendung wichtig.

2. Prüfen, ob bcrypt vorhanden ist

Nach der Installation kontrollieren wir das Gemfile:
grep -n "bcrypt" Gemfile
In unserem Testprojekt erschien:
gem "bcrypt", "~> 3.1.7"
Damit wissen wir, dass bcrypt zur Verfügung steht.

3. Benutzer mit dem b4um Scaffold erzeugen

Jetzt erzeugen wir unsere Benutzerverwaltung.
Auch dafür verwenden wir den b4um Generator:
bin/rails generate b4um:scaffold User name:string email:string password_digest:string
Besonders wichtig ist:
password_digest:string
Wir erzeugen kein Datenbankfeld password.
Das eingegebene Passwort wird später von bcrypt verarbeitet. In der Datenbank wird nur der daraus erzeugte Hash im Feld password_digest gespeichert.
Der b4um Generator erkennt dieses Feld und bereitet das Modell und das Formular entsprechend vor.
Anschließend führen wir die Migration aus:
bin/rails db:migrate

4. Was der b4um Generator für uns erzeugt hat

Sehen wir uns zunächst das User-Modell an:
cat app/models/user.rb
Es sollte folgendermaßen aussehen:
class User < ApplicationRecord
  has_secure_password
end
Die entscheidende Zeile ist:
has_secure_password
Dadurch stellt Rails unter anderem die virtuellen Attribute
password
password_confirmation
sowie die Methode
authenticate
zur Verfügung.
Das funktioniert zusammen mit unserem Datenbankfeld:
password_digest

5. Das von b4um erzeugte Formular

Jetzt kontrollieren wir:
cat app/views/users/_form.html.erb
Der b4um Generator hat für unser password_digest automatisch passende Passwortfelder erzeugt.
Der relevante Teil sieht so aus:
<div class="form-field">
  <%= form.text_field :name,
        class: "form-input",
        placeholder: " " %>
  <%= form.label :name, class: "form-label" %>
</div>

<div class="form-field">
  <%= form.email_field :email,
        class: "form-input",
        placeholder: " " %>
  <%= form.label :email, class: "form-label" %>
</div>

<div class="form-field">
  <%= form.password_field :password,
        class: "form-input",
        placeholder: " " %>
  <%= form.label :password, class: "form-label" %>
</div>

<div class="form-field">
  <%= form.password_field :password_confirmation,
        class: "form-input",
        placeholder: " " %>
  <%= form.label :password_confirmation, class: "form-label" %>
</div>
Das ist einer der Vorteile unseres b4um Scaffolds: Wir müssen die Passwortfelder nicht erst von Hand in das Formular einbauen.

6. Die erlaubten Parameter kontrollieren

Auch der Controller wurde entsprechend angepasst.
Wir können uns den relevanten Bereich ansehen:
grep -n -A8 "def user_params" app/controllers/users_controller.rb
Dort finden wir:
def user_params
  params.expect(user: [ :name, :email, :password, :password_confirmation ])
end
Auch hier taucht password_digest nicht im Formular auf.
Der Benutzer übermittelt:
password
password_confirmation
und has_secure_password kümmert sich um die Verarbeitung.

7. Einen Benutzer anlegen

Wir starten unseren Rails-Server, falls er noch nicht läuft:
bin/rails server
Anschließend öffnen wir:
http://localhost:3000/users/new
Wir können beispielsweise folgende Daten verwenden:
Name: Alex
E-Mail: info@example.com
Passwort: test1234
Passwortbestätigung: test1234
Nach dem Absenden wird der Benutzer angelegt.

Das vom b4um Scaffold erzeugte Formular enthält bereits Name, E-Mail-Adresse, Passwort und Passwortbestätigung.
Das vom b4um Scaffold erzeugte Formular enthält bereits Name, E-Mail-Adresse, Passwort und Passwortbestätigung.

Nach erfolgreicher Erstellung gelangen wir auf die Show-Seite des Benutzers.

Der Benutzer wurde erfolgreich erstellt. Das eingegebene Passwort wird selbstverständlich nicht angezeigt.
Der Benutzer wurde erfolgreich erstellt. Das eingegebene Passwort wird selbstverständlich nicht angezeigt.

8. Mit der Rails Console prüfen, was gespeichert wurde

Jetzt schauen wir uns genauer an, was bcrypt mit unserem Passwort gemacht hat.
Dafür öffnen wir ein zweites Terminalfenster und starten:
bin/rails console
Die Rails Console wird geöffnet.
Zunächst laden wir unseren ersten Benutzer:
user = User.first
Jetzt können wir einzelne Werte kontrollieren:
user.name
und:
user.email
Interessant wird es bei:
user.password_digest
Als Ergebnis erscheint eine lange Zeichenfolge, beispielsweise beginnend mit:
$2a$12$...
Unser eingegebenes Passwort test1234 steht dort also nicht im Klartext.
Stattdessen befindet sich dort der von bcrypt erzeugte Passwort-Hash.

bcrypt speichert nicht das eigentliche Passwort, sondern einen daraus erzeugten Passwort-Hash in password_digest.
bcrypt speichert nicht das eigentliche Passwort, sondern einen daraus erzeugten Passwort-Hash in password_digest.

9. Das Passwort direkt in der Console testen

Jetzt können wir sogar ausprobieren, ob Rails unser Passwort überprüfen kann.
Wir bleiben in der Rails Console und geben ein:
user.authenticate("test1234")
Wenn test1234 das richtige Passwort ist, erhalten wir den Benutzer zurück.
Probieren wir dagegen:
user.authenticate("falsches-passwort")
erhalten wir:
false
Genau diese Funktion werden wir gleich für unser Login verwenden.

authenticate liefert bei einem korrekten Passwort den Benutzer zurück. Bei einem falschen Passwort erhalten wir false.
authenticate liefert bei einem korrekten Passwort den Benutzer zurück. Bei einem falschen Passwort erhalten wir false.

Rails Console wieder verlassen

Nachdem wir unseren Test abgeschlossen haben, verlassen wir die Rails Console mit:
exit
Danach befinden wir uns wieder in unserem normalen Terminal.

10. Einen SessionsController mit b4um erzeugen

Für die Anmeldung benötigen wir einen SessionsController.
Auch hier gehen wir zunächst über den b4um Generator:
bin/rails generate b4um:controller Sessions new create destroy
Der Generator erzeugt unter anderem:
app/controllers/sessions_controller.rb

app/views/sessions/new.html.erb
app/views/sessions/create.html.erb
app/views/sessions/destroy.html.erb
Außerdem erzeugt der Controller-Generator zunächst Routen und Navigationseinträge für alle drei Actions.
Das ist für normale Inhaltsseiten sinnvoll.
Bei einer Session haben create und destroy jedoch eine andere Aufgabe:
  • new zeigt unser Login-Formular.
  • create verarbeitet die Anmeldung.
  • destroy meldet den Benutzer ab.
Wir benötigen deshalb nicht drei sichtbare Seiten in der Navigation.

11. Die automatisch erzeugten Session-Routen ersetzen

Der Generator hat zunächst Routen wie diese angelegt:
get "sessions/new"
get "sessions/create"
get "sessions/destroy"
Diese entfernen wir aus:
config/routes.rb
Stattdessen tragen wir ein:
get    "login",  to: "sessions#new",     as: :login
post   "login",  to: "sessions#create"
delete "logout", to: "sessions#destroy", as: :logout
Damit entspricht auch die HTTP-Methode der jeweiligen Aufgabe:
GET     /login    → Login-Formular anzeigen
POST    /login    → Login durchführen
DELETE  /logout   → Session beenden
Anschließend kontrollieren wir die Routen:
bin/rails routes | grep -E "login|logout"
Wir sollten ungefähr Folgendes erhalten:
login  GET     /login   sessions#new
       POST    /login   sessions#create
logout DELETE  /logout  sessions#destroy

12. Die vom Generator erzeugte Navigation aufräumen

Der b4um Controller-Generator hat beim Erzeugen des SessionsControllers zunächst drei Navigationseinträge angelegt:
New
Create
Destroy
Wir öffnen:
app/views/shared/_navigation.html.erb
und entfernen diese drei automatisch erzeugten Einträge wieder.
Das ist wichtig:
Create und Destroy sind Aktionen und keine Seiten, die der Benutzer direkt über die Navigation öffnen soll.
Auch New soll später nicht New, sondern sinnvollerweise Login heißen.

13. Den SessionsController programmieren

Jetzt öffnen wir:
app/controllers/sessions_controller.rb
und ersetzen den Inhalt durch:
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      session[:user_id] = user.id

      redirect_to root_path, notice: "Successfully logged in."
    else
      flash.now[:alert] = "Invalid email or password."
      render :new, status: :unprocessable_content
    end
  end

  def destroy
    session.delete(:user_id)

    redirect_to root_path, notice: "Successfully logged out."
  end
end
Sehen wir uns den entscheidenden Teil genauer an:
user = User.find_by(email: params[:email])
Damit suchen wir den Benutzer anhand der eingegebenen E-Mail-Adresse.
Anschließend:
user&.authenticate(params[:password])
Hier kommt unsere zuvor in der Rails Console getestete authenticate-Methode zum Einsatz.
Stimmt das Passwort, speichern wir:
session[:user_id] = user.id
Damit merkt sich Rails, welcher Benutzer angemeldet ist.
Beim Logout entfernen wir diese Information wieder:
session.delete(:user_id)

14. Das Login-Formular erstellen

Die vom Controller-Generator erzeugte Platzhalterseite ersetzen wir.
Wir öffnen:
app/views/sessions/new.html.erb
und verwenden:
<% content_for :title, "Login" %>

<div class="page-header">
  <h1>Login</h1>
</div>

<div class="b4um-grid">
  <section class="b4um-card b4um-card--full">
    <h2 class="b4um-card__title">
      Sign in
    </h2>

    <%= form_with url: login_path, class: "form" do |form| %>
      <div class="form-field">
        <%= form.email_field :email,
              class: "form-input",
              placeholder: " ",
              required: true %>
        <%= form.label :email, "Email", class: "form-label" %>
      </div>

      <div class="form-field">
        <%= form.password_field :password,
              class: "form-input",
              placeholder: " ",
              required: true %>
        <%= form.label :password, "Password", class: "form-label" %>
      </div>

      <div class="form-actions">
        <%= form.submit "Login", class: "form-submit" %>
      </div>
    <% end %>
  </section>
</div>
Dabei verwenden wir bewusst die bereits vorhandenen b4um Klassen:
form
form-field
form-input
form-label
form-actions
form-submit
Wir bauen also kein neues Formular-CSS für den Login.

Das Login-Formular verwendet die bereits vorhandenen Form-Komponenten des b4um Generators.
Das Login-Formular verwendet die bereits vorhandenen Form-Komponenten des b4um Generators.

15. ApplicationController um Login-Helfer erweitern

Als Nächstes öffnen wir:
app/controllers/application_controller.rb
Die bereits vorhandenen Rails-Einstellungen lassen wir bestehen und ergänzen darunter unsere Authentifizierungsfunktionen.
Der Controller sieht anschließend so aus:
class ApplicationController < ActionController::Base
  # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  allow_browser versions: :modern

  # Changes to the importmap will invalidate the etag for HTML responses
  stale_when_importmap_changes

  helper_method :current_user, :logged_in?

  private

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def logged_in?
    current_user.present?
  end

  def require_login
    return if session[:user_id].present?

    redirect_to login_path, alert: "Please log in first."
  end
end
Schauen wir uns die Ergänzungen einzeln an.

current_user

def current_user
  @current_user ||= User.find_by(id: session[:user_id])
end
Damit können wir den momentan angemeldeten Benutzer ermitteln.

logged_in?

def logged_in?
  current_user.present?
end
Damit können wir einfach fragen:
logged_in?
und erhalten sinngemäß true oder false.

require_login

def require_login
  return if session[:user_id].present?

  redirect_to login_path, alert: "Please log in first."
end
Diese Methode verwenden wir später als Schutz für Controller-Aktionen.

Warum helper_method?

Oben haben wir außerdem:
helper_method :current_user, :logged_in?
eingetragen.
Controller-Methoden stehen einer View nicht automatisch als normale View-Helper zur Verfügung.
Mit helper_method machen wir diese beiden Methoden auch in unseren ERB-Templates verfügbar.
Dadurch können wir später beispielsweise schreiben:
<% if logged_in? %>
und abhängig vom Login-Status Elemente anzeigen oder ausblenden.
require_login geben wir dagegen bewusst nicht als Helper frei. Diese Methode gehört in den Controller und schützt dort unsere Actions.

16. Den vorhandenen NavigationHelper erweitern

Für Login und Logout möchten wir weiterhin das bereits vorhandene Navigationssystem des b4um Generators verwenden.
Dabei gibt es allerdings einen wichtigen Unterschied:
Der Login ist ein normaler Link auf:
GET /login
Der Logout verwendet dagegen:
DELETE /logout
Für den Login eignet sich deshalb unser bereits vorhandener navigation_link_to.
Für den Logout möchten wir dagegen Rails button_to verwenden. Dadurch kann Rails sauber einen DELETE-Request absenden.
Damit der Logout-Button trotzdem genauso aussieht wie die übrigen Links in der Navigation, erweitern wir den vorhandenen NavigationHelper.
Wir öffnen:
app/helpers/navigation_helper.rb
Bisher enthält er bereits unseren Helper:
navigation_link_to
Unterhalb davon ergänzen wir:
navigation_button_to
Die vollständige Datei sieht anschließend so aus:
# frozen_string_literal: true

module NavigationHelper
  def navigation_link_to(name, path, controller: nil, action: nil)
    classes = [ "navigation__link" ]

    active =
      if controller && action
        controller_name == controller.to_s &&
          action_name == action.to_s
      elsif controller
        controller_name == controller.to_s
      else
        current_page?(path)
      end

    classes << "navigation__link--active" if active

    link_to(
      name,
      path,
      class: classes,
      data: { action: "click->navigation#close" }
    )
  end

  def navigation_button_to(name, path, method:)
    button_to(
      name,
      path,
      method: method,
      class: "navigation__link",
      form: {
        class: "navigation__form",
        data: { action: "click->navigation#close" }
      }
    )
  end
end
Damit besitzen wir jetzt zwei passende Helfer für unsere Navigation:
navigation_link_to
für normale Links und:
navigation_button_to
für Aktionen, die über eine andere HTTP-Methode ausgeführt werden sollen.

Warum verwenden wir für Logout button_to?

Unsere Logout-Route lautet:
DELETE /logout
Ein normaler HTML-Link führt zunächst einen GET-Request aus.
Rails button_to erzeugt dagegen ein kleines Formular und kann dadurch zuverlässig unsere gewünschte HTTP-Methode verwenden:
method: :delete
Genau deshalb verwenden wir für Logout:
navigation_button_to "Logout", logout_path, method: :delete
Der Helper übernimmt dabei gleichzeitig die vorhandene b4um Klasse:
class: "navigation__link"
Der Logout bleibt technisch also ein Button, wird optisch aber Bestandteil unserer normalen Navigation.
Auch für die mobile Navigation ist bereits gesorgt:
data: { action: "click->navigation#close" }
befindet sich beim normalen Navigationslink direkt am Link.
Beim button_to setzen wir diese Action auf das erzeugte Formular:
form: {
  class: "navigation__form",
  data: { action: "click->navigation#close" }
}
Damit können wir das bestehende Verhalten der b4um Navigation weiterverwenden.

17. Vorhandenes Navigations-CSS für Links und Buttons anpassen

Unser neuer Logout verwendet zwar:
class: "navigation__link"
aber button_to erzeugt tatsächlich ein HTML-button-Element.
Browser geben Buttons standardmäßig Eigenschaften wie einen Rahmen, einen Hintergrund und eine eigene Schriftformatierung.
Dadurch sah unser Logout beim ersten Versuch anders aus als die übrigen Navigationspunkte.
Wir möchten aber kein eigenes Logout-Design erstellen.
Stattdessen passen wir die bereits vorhandene Klasse:
.navigation__link
so an, dass sie sowohl mit einem normalen Link als auch mit einem Button funktioniert.
Wir öffnen:
app/assets/stylesheets/b4um/navigation.css
und suchen:
.navigation__link
Diesen Block ändern wir auf:
.navigation__link {
  position: relative;
  padding: 0.5rem 0;

  border: 0;
  background: transparent;

  color: var(--b4um-text);
  font: inherit;
  font-weight: 500;
  text-decoration: none;

  cursor: pointer;

  transition:
    color 0.18s ease,
    background-color 0.18s ease,
    transform 0.18s ease;
}
Gegenüber der ursprünglichen Variante sind insbesondere diese Angaben wichtig:
border: 0;
background: transparent;
Damit entfernen wir die standardmäßige Button-Darstellung des Browsers.
Mit:
font: inherit;
übernimmt der Button die Schriftformatierung seiner Umgebung und sieht nicht mehr wie ein typischer Browser-Button aus.
Und:
cursor: pointer;
sorgt auch beim Button für den erwarteten Mauszeiger.
Die bestehenden Eigenschaften:
color: var(--b4um-text);
font-weight: 500;
text-decoration: none;
bleiben erhalten.
Dadurch verwenden Link und Button dasselbe Erscheinungsbild.
Auch die bereits vorhandenen Hover-Effekte können weiterhin verwendet werden:
.navigation__link:hover {
  color: var(--b4um-primary);
  transform: scale(1.04);
}
Der Logout-Button verhält sich dadurch optisch genauso wie beispielsweise Home, Articles oder Users.

Das von button_to erzeugte Formular

Unser neuer Helper versieht das von Rails erzeugte Formular zusätzlich mit:
class: "navigation__form"
Falls das Formular durch vorhandene globale Formularregeln Abstände erzeugt, können wir diese ebenfalls in navigation.css neutralisieren.
Dazu ergänzen wir:
.navigation__form {
  margin: 0;
  padding: 0;
}
Damit hat das technische Formular um den Logout-Button keinen Einfluss auf die Anordnung der Navigation.
Wir erstellen also keinen speziellen blauen oder umrandeten Logout-Button.
Stattdessen gilt:
Login  → normaler Navigationslink
Logout → button_to mit derselben Navigationsdarstellung
Das ist genau das gewünschte Verhalten: technisch unterschiedliche Elemente, optisch aber eine einheitliche b4um Navigation.

18. Login und Logout in die b4um Navigation einbauen

Jetzt können wir die beiden Helper in unserer Navigation verwenden.
Wir öffnen:
app/views/shared/_navigation.html.erb
Die drei zuvor vom Sessions-Generator erzeugten Navigationseinträge:
New
Create
Destroy
haben wir bereits entfernt.
An deren Stelle verwenden wir jetzt abhängig vom Anmeldestatus entweder Login oder Logout:
<% if logged_in? %>
  <%= navigation_button_to "Logout", logout_path, method: :delete %>
<% else %>
  <%= navigation_link_to "Login", login_path, controller: :sessions, action: :new %>
<% end %>
Hier greifen jetzt mehrere Teile unserer bisherigen Arbeit ineinander.
Ist niemand angemeldet, wird ausgeführt:
<%= navigation_link_to "Login",
      login_path,
      controller: :sessions,
      action: :new %>
Dadurch verwendet der Login den bereits vorhandenen b4um NavigationHelper.
Da wir zusätzlich:
controller: :sessions,
action: :new
angeben, kann navigation_link_to erkennen, wann wir uns auf der Login-Seite befinden.
Dann ergänzt der Helper automatisch:
navigation__link--active
und der Login erhält dieselbe aktive Markierung wie die übrigen Navigationspunkte.
Ist dagegen ein Benutzer angemeldet, wird ausgeführt:
<%= navigation_button_to "Logout",
      logout_path,
      method: :delete %>
Unser neuer Helper erzeugt daraus einen Rails-Button, der:
DELETE /logout
aufruft.
Gleichzeitig erhält der Button:
navigation__link
und sieht dadurch genauso aus wie die übrigen Links.
Wir brauchen also weder JavaScript für die DELETE-Methode noch einen gesonderten Logout-Button im Design.

19. Login und Logout testen

Jetzt testen wir das Zusammenspiel.
Zunächst rufen wir auf:
http://localhost:3000/login
Solange wir nicht angemeldet sind, sehen wir in der Navigation:
Login
Auf der Login-Seite wird dieser Navigationspunkt durch unseren vorhandenen navigation_link_to außerdem als aktiv markiert.
Wir melden uns jetzt mit unserem zuvor erstellten Benutzer an.
Nach erfolgreicher Anmeldung führt unser SessionsController aus:
session[:user_id] = user.id
und leitet uns auf die Startseite weiter.
Dort erscheint die Meldung:
Successfully logged in.
Gleichzeitig liefert:
logged_in?
nun true.
Deshalb wird in der Navigation nicht mehr:
Login
angezeigt, sondern:
Logout
Der entscheidende Unterschied ist im Browser nicht mehr sichtbar:
Login ist technisch ein normaler Link.
Logout ist technisch ein von Rails mit button_to erzeugter Button innerhalb eines Formulars.
Durch unseren erweiterten NavigationHelper und die Anpassung von:
.navigation__link
sehen beide jedoch wie normale b4um Navigationspunkte aus.

Nach erfolgreicher Anmeldung wechselt die Navigation von Login zu Logout. Der Logout ist technisch ein Rails button_to, verwendet aber dieselbe Darstellung wie die übrigen b4um Navigationslinks.
Nach erfolgreicher Anmeldung wechselt die Navigation von Login zu Logout. Der Logout ist technisch ein Rails button_to, verwendet aber dieselbe Darstellung wie die übrigen b4um Navigationslinks.

Logout testen

Jetzt klicken wir auf:
Logout
Unser Helper:
navigation_button_to
sendet dadurch:
DELETE /logout
an:
SessionsController#destroy
Dort wird:
session.delete(:user_id)
ausgeführt.
Anschließend ist der Benutzer abgemeldet und in der Navigation erscheint wieder:
Login
Damit haben wir jetzt eine Navigation, die nicht nur optisch einheitlich ist, sondern auch die richtigen HTTP-Methoden für Login und Logout verwendet.

20. Article-Aktionen schützen

Jetzt verwenden wir unsere Anmeldung für einen praktischen Zugriffsschutz.
Wir öffnen:
app/controllers/articles_controller.rb
und ergänzen am Anfang:
before_action :require_login,
  only: %i[ new create edit update destroy ]
Zusammen mit dem bereits vorhandenen Callback sieht der Anfang beispielsweise so aus:
class ArticlesController < ApplicationController
  before_action :require_login,
    only: %i[ new create edit update destroy ]

  before_action :set_article,
    only: %i[ show edit update destroy ]

  # ...
end
Damit bleiben:
index
show
öffentlich erreichbar.
Geschützt sind dagegen:
new
create
edit
update
destroy
Ein Besucher darf also Artikel ansehen, aber nicht verändern.

21. Schutz ohne Login testen

Jetzt melden wir uns über Logout ab.
Anschließend versuchen wir direkt:
http://localhost:3000/articles/new
aufzurufen.
require_login erkennt, dass keine Benutzer-ID in der Session vorhanden ist und führt aus:
redirect_to login_path, alert: "Please log in first."
Wir landen deshalb wieder auf der Login-Seite und sehen:
Please log in first.
Dasselbe geschieht, wenn wir die URL einer Edit-Seite direkt aufrufen.

Der Zugriffsschutz funktioniert auch bei direkter Eingabe einer geschützten URL. Der Benutzer wird zum Login weitergeleitet.
Der Zugriffsschutz funktioniert auch bei direkter Eingabe einer geschützten URL. Der Benutzer wird zum Login weitergeleitet.

22. „New article“ nur nach dem Login anzeigen

Jetzt verbessern wir zusätzlich die Benutzeroberfläche.
Wir öffnen:
app/views/articles/index.html.erb
und ändern den Kopfbereich auf:
<% content_for :title, "Articles" %>

<div class="page-header">
  <h1>Articles</h1>

  <% if logged_in? %>
    <%= link_to "New article",
          new_article_path,
          class: "button button--primary" %>
  <% end %>
</div>
Der übrige Teil der Seite kann bestehen bleiben:
<% if @articles.any? %>
  <%= render "bento", articles: @articles %>
<% else %>
  <section class="b4um-empty-state">
    <h2 class="b4um-empty-state__title">
      No articles yet.
    </h2>

    <p class="b4um-empty-state__text">
      Create your first article to get started.
    </p>
  </section>
<% end %>
Durch:
<% if logged_in? %>
wird New article nur angezeigt, wenn ein Benutzer angemeldet ist.
Dabei verwenden wir wiederum eine bereits vorhandene b4um Button-Klasse:
button button--primary
Wir benötigen dafür also ebenfalls kein neues CSS.

23. Edit und Destroy ausblenden

Dasselbe machen wir auf der Show-Seite.
Wir öffnen:
app/views/articles/show.html.erb
und verwenden:
<% content_for :title, "Article" %>

<div class="page-header">
  <h1>Article</h1>
</div>

<div class="b4um-grid">
  <article class="b4um-card b4um-card--full">
    <%= render @article %>

    <div class="b4um-card__actions">
      <% if logged_in? %>
        <%= link_to "Edit",
              edit_article_path(@article),
              class: "button button--secondary" %>
      <% end %>

      <%= link_to "Back",
            articles_path,
            class: "button button--secondary" %>

      <% if logged_in? %>
        <%= button_to "Destroy",
              @article,
              method: :delete,
              class: "button button--danger",
              form: {
                data: {
                  turbo_confirm: "Are you sure?"
                }
              } %>
      <% end %>
    </div>
  </article>
</div>
Nicht angemeldete Besucher sehen damit nur:
Back
Angemeldete Benutzer sehen:
Edit
Back
Destroy

Öffentliche Inhalte bleiben sichtbar, während Bearbeitungs- und Löschfunktionen für nicht angemeldete Besucher ausgeblendet werden.
Öffentliche Inhalte bleiben sichtbar, während Bearbeitungs- und Löschfunktionen für nicht angemeldete Besucher ausgeblendet werden.

24. Warum das Ausblenden allein nicht reicht

An dieser Stelle ist ein Unterschied besonders wichtig.
Mit:
<% if logged_in? %>
verstecken wir Funktionen lediglich in der Benutzeroberfläche.
Das ist komfortabel, aber noch kein ausreichender Zugriffsschutz.
Jemand könnte theoretisch versuchen, die entsprechende URL direkt aufzurufen.
Deshalb haben wir zusätzlich im Controller:
before_action :require_login,
  only: %i[ new create edit update destroy ]
eingebaut.
Damit haben wir zwei Ebenen:

View

<% if logged_in? %>
sorgt für eine saubere Benutzeroberfläche.

Controller

before_action :require_login
sorgt für den tatsächlichen serverseitigen Zugriffsschutz.
Beides zusammen ergibt das gewünschte Verhalten.

25. Geschützte Funktion nach dem Login testen

Jetzt melden wir uns wieder an.
Anschließend öffnen wir:
/articles
Jetzt erscheint wieder:
New article
Klicken wir darauf, öffnet sich das vom b4um Scaffold erzeugte Formular.
In unserem Beispiel enthält es:
Title
Description
Image

Nach erfolgreicher Anmeldung stehen die geschützten Funktionen wieder zur Verfügung.
Nach erfolgreicher Anmeldung stehen die geschützten Funktionen wieder zur Verfügung.

26. Logout testen

Zum Schluss klicken wir in der Navigation auf:
Logout
Unser Link sendet durch:
data: { turbo_method: :delete }
einen DELETE-Request an:
/logout
Dadurch wird im SessionsController ausgeführt:
session.delete(:user_id)
Der Benutzer ist anschließend nicht mehr angemeldet.
In der Navigation erscheint wieder:
Login
und geschützte Funktionen sind nicht mehr verfügbar.

27. Was bcrypt dabei eigentlich macht

Zum Abschluss lohnt sich noch einmal der Blick auf den gesamten Passwortablauf.
Unser Formular besitzt:
password
password_confirmation
Unser Datenbankmodell besitzt dagegen:
password_digest
Durch:
has_secure_password
übernimmt Rails zusammen mit bcrypt die Verarbeitung.
Wenn der Benutzer beispielsweise eingibt:
test1234
wird dieser Wert nicht als Klartext gespeichert.
Stattdessen landet ein bcrypt-Hash in:
password_digest
Bei:
user.authenticate("test1234")
kann bcrypt anschließend überprüfen, ob das eingegebene Passwort zu diesem Hash gehört.
Genau deshalb konnten wir in unserer Rails Console sehen:
user.authenticate("test1234")
→ Benutzer
und:
user.authenticate("falsches-passwort")
→
false
Das Klartextpasswort muss dafür nicht aus der Datenbank zurückgelesen werden.

28. Ergebnis

Wir haben unsere bestehende b4um-Anwendung um eine vollständige einfache Benutzeranmeldung erweitert und sind dabei konsequent von den Funktionen des b4um Generators ausgegangen.
Mit:
bin/rails generate b4um:install
haben wir die bcrypt-Unterstützung aktiviert beziehungsweise überprüft.
Mit:
bin/rails generate b4um:scaffold User name:string email:string password_digest:string
haben wir Benutzer-Modell, Controller und bereits passend gestaltete Formulare erhalten.
Mit:
bin/rails generate b4um:controller Sessions new create destroy
haben wir die Ausgangsstruktur für die Session-Verwaltung erzeugt und anschließend an die besonderen Anforderungen eines Logins angepasst.
Dabei konnten wir außerdem vorhandene b4um Komponenten weiterverwenden:
form-input
form-label
form-submit

button
button--primary
button--secondary
button--danger

navigation__link
navigation__link--active
Auch der vorhandene:
navigation_link_to
Helper bleibt Bestandteil der Lösung.
Damit mussten wir für Login, Logout und die geschützten Article-Funktionen kein separates Designsystem aufbauen.
Unsere Anwendung unterscheidet nun zwischen öffentlichen und geschützten Funktionen:
Öffentlich:
Articles anzeigen
einzelnen Article anzeigen
Login
Nur angemeldet:
neuen Article erstellen
Article bearbeiten
Article löschen
Logout
Und besonders wichtig: Der Schutz besteht nicht nur optisch. Auch ein direkter Aufruf einer geschützten URL wird durch require_login abgefangen.
Damit haben wir mit bcrypt, Rails Sessions und den bestehenden Komponenten des b4um Generators eine kompakte Benutzeranmeldung mit echtem serverseitigem Zugriffsschutz aufgebaut.

Meld dich an und schreibe ein Kommentar