Wir wollen am Beispiel einer Nutzerverwaltung die Verwendung von Subject, Observable, Observer und Guards demonstrieren. Alle diese Konzepte werden im Frontend verwendet. Subject, Observable, Observer dienen dazu, Werte an Subscriber zu propagieren. Eine gute Übersicht über Subject, Observable, Observer bietet die folgende Abbildung (hier entnommen).
Subject, Observable, Observer finden sich im RxJS-Paket. Subject hat den großen Vorteil, dass ein (neuer) Wert an viele Subscriber gesendet (multicast) werden kann. Wir werden Subjects z.B. verwenden, um der nav-Komponente mitzuteilen, dass sich eine Nutzerin ein- bzw. ausgelogged hat. Ein Subject ist sowohl ein Observer als auch ein Observable. Observable kann mehrere Werte (nacheinander) pushen (an die Subscriber). Folgende Tabelle aus gibt einen guten Überblick über die Funktionalität eines *Observable*s.
Ein Observer konsumiert die Werte, die ein Observable liefert. Alle Funktionen des HTTP-Clients sind Observables. Sie liefern die Werte vom Backend (mittels einer next-Funktion). Mithilfe eines Observers werden wir diese Werte empfangen (next, error, complete).
Wir werden in diesem Abschnitt unterschiedliche "Sicherheitskonzepte" umsetzen. Einerseits werden wir die REST-API so gestalten, dass nicht alle Endpunkte frei verfügbar sind, sondern für einige Endpunkte nur dann eine wirksame Funktionalität ausgeführt wird, wenn sich der Aufrufer des Endpunktes als Administrator "ausweisen" kann. Wir werden dazu JSON Web Tokens verwenden.
Außerdem zeigen wir, wie wir Passworte verschlüsselt in der Datenbank ablegen und wie dann ein Passwort-Vergleich durchgeführt wird. Dazu verwenden wir bcrypt.
Mithilfe von Guards wird die Verwendung von Komponenten im Frontend gesteuert. Eine Komponente soll z.B. nur dann aufgerufen werden können, wenn die Nutzerin eingelogged ist.
Der letzte Punkt betrifft das Frontend. Die ersten beiden Punkte betreffen das Backend. Damit fangen wir an.
Abgesicherte REST-API zur Nutzerverwaltung (Backend)¶
Folgende Endpunkte soll die REST-API zur Verfügung stellen:
Endpunkt
Beschreibung
Abgesichert
GET /user
gebe alle user-Einträge zurück
kann nur durch einen admin aufgerufen werden
GET /user/:username
gibt den user mit username zurück
kann nur durch einen admin aufgerufen werden
POST /user/register
erstelle einen neuen user (Registrierung-Funktion)
frei verfügbar, Registrierung jedoch nur als Rolle user
POST /user/login
Prüft, ob username existiert und ob das Passwort stimmt (Login-Funktion)
frei verfügbar
DELETE /user/:id
löscht den user mit id == id
kann nur durch einen admin aufgerufen werden
PUT /changepasswaord
ändert das Passwort einer Nutzerin
frei verfügbar
PUT /setadmin
setzt die Rolle für eine Nutzerin von user auf admin
constexpress=require('express')constcors=require('cors')require('dotenv').config()constroutes=require('./routes')constapp=express()constPORT=3000app.use(express.json())app.use(cors())app.use('/user',routes)app.listen(PORT,(err)=>{if(err){console.log('backend not started',err)}else{console.log(`Server started and listening on port ${PORT} ...`)}})
Die Verbindungsdaten zur PostgreSQL-Datenbank stehen in der .env-Datei. Die Verbindung zur Datenbank stellen wir im Skript db.js her:
constpg=require('pg');constdb=newpg.Client({user:process.env.PGUSER,host:process.env.PGHOST,database:process.env.PGDATABASE,password:process.env.OCEAN_PASSWORD,/* bei Ihnen PGPASSWORD */port:process.env.PGPORT,});db.connect(err=>{if(err){console.log(err);}else{console.log('Connection to DB ...');}});module.exports=db;
Für die Endpunkte (Routen) erstellen wir ein Skript routes.js:
constexpress=require('express')constrouter=express.Router();constbcrypt=require('bcrypt')constdb=require('./db')varjwt=require('jsonwebtoken');// call only once at the beginning - creates table users (id, username, password, email, role)router.get('/createtable',async(req,res)=>{awaitdb.query('DROP TABLE IF EXISTS users; CREATE TABLE users(id serial PRIMARY KEY, username VARCHAR(50), password VARCHAR(255), email VARCHAR(50), role VARCHAR(50));')res.send({message:`table users in database ${process.env.PGDATABASE} created`})})/* hier fügen wir im Folgenden die weiteren Endpunkte hinzu */module.exports=router;
Wir haben zunächst einen Endpunkt implementiert, den wir einmalig zum Erstellen der Tabelle users in der Datenbank benötigen. Wenn wir das Backend mit
node--watchserver.js
starten und den Endpunkt GET localhost:3000/user/createtable aufrufen (im Browser oder in Postman), dann wird in unserer Datenbank (in meinem Beispiel users_jf) eine Tabelle users mit den Spalten id, username, password, email und role erzeugt.
// post one user - register as role userrouter.post('/register',async(req,res)=>{letusername=req.body.username;letpassword=req.body.password;lethashPassword=awaitbcrypt.hash(password,10);console.log('hash : ',hashPassword)letemail=req.body.email;letcheck=awaitdb.query('SELECT * FROM users WHERE email = $1 OR username = $2',[email,username])if(check.rowCount>0){res.status(401)res.send({message:`E-Mail ${email} and/or username ${username} already exists`})}else{constquery=`INSERT INTO users(username, password, email, role) VALUES ($1, $2, $3, $4) RETURNING *`;letresult=awaitdb.query(query,[username,hashPassword,email,'user']);res.status(201)res.send(result.rows[0])}})
Erläuterungen:
zur Registrierung muss das JSON-Objekt im body des requests Werte für die Eigenschaften username, password und email enthalten.
Die Registrierung wird nur dann vorgenommen, wenn weder username noch email bereits in der Datenbank enthalten sind. Wenn einer der beiden Werte bereits vorkommt, wird Status-Code 401 gesendet und die Nachricht E-Mail ${email} and/or username ${username} already exists (Zeilen 23-26).
Wir verschlüsseln die Passwörter mithilfe von bcrypt. Dazu installieren wir uns dieses Paket zunächst mit npm i bcrypt (siehe oben). Eingebunden wird es mithilfe von const bcrypt = require('bcrypt') (siehe oben).
In Zeile 18 wird die hash()-Funktion von bcrypt aufgerufen. Das password wird als erster Parameter übergeben. Die 10 ist der Wert für die saltRounds und ist der empfohlene Wert. Der hash wird erzeugt und als Wert der Variablen hashPassword zuegwiesen. Dieser Hash-Wert wird in der Datenbank gespeichert (Zeile 29).
Es bleibt anzumerken, dass aus dem hash nicht wieder das Passwort rückerzeugt werden kann. Um sich einzuloggen, muss das einegebene Passwort mit dem hash verglichen werden. Dazu stellt bcrypt ebenfalls eine Funktion zur Verfügung. Diese verwenden wir beim Login.
Eine Login-Funktion soll überprüfen, ob ein username existiert und ob das dazugehörige password korrekt ist. Dazu müssen beide Informationen mit dem Request übermittelt werden. Deshalb wird als Anfragemethode POST verwendet. Um diesen POST-Endpunkt vom vorherigen Endpunkt zu unterscheiden, wird der URL anstelle von /register hier /login angehängt.
Die Implementierung dieser Funktion in der routes.js könnte wie folgt aussehen:
// post one user - loginrouter.post('/login',async(req,res)=>{letusername=req.body.username;letpassword=req.body.password;letresult=awaitdb.query('SELECT * FROM users WHERE username = $1',[username])if(result.rowCount>0){constuser=result.rows[0];constmatch=awaitbcrypt.compare(password,user.password);if(match){constuserWithoutPassword={id:user.id,username:user.username,role:user.role,email:user.email};consttoken=jwt.sign(userWithoutPassword,username);res.status(200)res.send({token:token,user:userWithoutPassword})}else{res.status(401)res.send({message:"username/password wrong"})/* hier weiß man Passwort falsch */}}else{res.status(401)res.send({message:"username/password wrong"})/* hier weiß man, username falsch */}})
Erläuterungen:
Es wird zunächst geprüft, ob es überhaupt einen passenden username in der Datenbank gibt (Zeile 40). Existiert ein solcher EIntrag nicht, wird HTTP-Status 401 zurückgesendet mit der Meldung username/password wrong. Man könnte hier natürlich auch konkreter username does not exist zurückgeben, aber zu viele Details bei einem fehlerhaften Login-Versuch sind kein guter Datenschutz.
Existiert ein Eintrag für username, wird das password dieses Eintrages mit dem password aus dem Request unter Verwendung der compare()-Funktion von bcrypt miteinander verglichen (Zeile 43). Sind die Passwärter gleich, ist match == true.
Für den Fall, dass match==true ist, wird die Nutzerin eingelogged. Dafür wird mithilfe des jsonwebtoken-Paketes ein solcher JSON Web Token (jwt) erzeugt. Dies geschieht mithilfe der sign()-Funktion. Der Token enthält die Daten über die Nutzerin (außer das gehashte Passwort) als payload. Als Sicherheitsschlüssel wird hier der username verwendet (siehe jsonwebtoken).
Für den Fall, dass match==false ist, stimmte das Passwort nicht. Auch hier geben wir 401 und username/password wrong zurück und geben keine weiteren Informationen preis.
Das Auslesen aller Nutzerinnen soll nur einem Nutzer in der Rolle admin möglich sein. Wir haben derzeit keine Möglichkeit, einen admin-Nutzer zu kreieren. Wir werden zwar später noch einen Endpunkt implementieren, der für eine Nutzerin die Rolle user in die Rolle admin wechselt, aber auch dies wird nur einem admin möglich sein. Wir benötigen also einmalig einen Nutzer in der Rolle admin in unserer Tabelle. Diesen können wir entweder über
direkt in die Tabelle mit z.B. INSERT INTO users(username, password, email, role) VALUES ("admin1", hashPassword, "admin1@test.de", "admin") einrichten. Das hasPassword lässt sich z.B. auf bcrypt.online erzeugen. Oder wir passen den Endpunkt GET /user/createtablle kurz wie folgt an:
// call only once at the beginning - creates table users (id, username, password, email, role)router.get('/createtable',async(req,res)=>{constpassword=awaitbcrypt.hash('pass1234',10)constquery=`INSERT INTO users(username, password, email, role) VALUES ($1, $2, $3, $4);`awaitdb.query('DROP TABLE IF EXISTS users;')awaitdb.query('CREATE TABLE users(id serial PRIMARY KEY, username VARCHAR(50), password VARCHAR(255), email VARCHAR(50), role VARCHAR(50));')awaitdb.query(query,["admin1",password,"admin1@test.de","admin"])res.send({message:`table users in database ${process.env.PGDATABASE} created`})})
Dann sollten wir ihn aber nach einmaliger Ausführung auch wieder aus unserem Backend entfernen.
Nun haben wir eine Nutzerin in der admin-Rolle und erzeugen beim Einloggen (wie für alle Nutzerinnen beim Einloggen) ein JSON-Web-Token (jwt).
Über JSON-Web-Tokens (JWT) können Sie sich hier und aber auch in dem frei verfügbaren Handbuch informieren. JWT ist ein offener Standard und dient dem sicheren Informationsaustausch zwischen zwei Parteien. Am häufigsten wird JWT zur Autorisierung, d.h. zur Überprüfung von Zugriffsrechten genutzt.
Autorisierung vs. Authentififizierung
Durch Authentifizierung wird bestätigt, dass Benutzer diejenigen sind, die sie zu sein vorgeben, während diese Benutzer per Autorisierung die Erlaubnis erhalten, auf Ressourcen zuzugreifen.
Um eine Ressource nutzen zu können, autorisiert man den Zugriff mithilfe eines Tokens. Wenn wir einen Endpunkt einer REST-API nutzen wollen, dann senden wir diesen Token im header des Requests mit. Zunächst muss der Token jedoch erzeugt werden. Dies passiert beim Login. Wir schauen uns einen solchen Token im Detail an:
Ein JWT besteht aus
einem Header,
einem Paload und
einer Signatur.
Der Header enthält zwei Einträge: den Typ des Tokens (JWT) und den Algorithmus, der zum Signieren des Tokens verwendet wird, z.B. HMAC SH265 oder RSA. Ein Beispiel für den Header könnte sein:
{"alg":"HS256","typ":"JWT"}
Der Payload enthält typischerweise Daten über den Nutzer, z.B.
Die Signatur verschlüsselt den Header und den Payload mit dem angegebenen Signaturalgorithmus und verwendet dabei ein secret. Dieses secret kann ein öffentlicher Schlüssel oder einfach ein alphanumerischer String sein.
Zur Erzeugung und Prüfung von JWT verwenden wir in unserem Backend das jsonwebtoken-Paket. Zur Erzeugung des Tokens stellt dieses Package die Funktion sign() zur Verfügung.
Wir zeigen zunächst nochmal den Code, der beim Login zur Erstellung des Tokens führt:
Wir verwenden darin das userWithoutPassword-Objekt als Payload des JWT und den username als secret. Wenn nun ein Endpunkt angesprochen wird, der eine Autorisierung verlangt, prüfen wir folgendes:
/* ------------------ check if caller is admin ---- start --------------------- */console.log('request headers: ',req.headers)consttoken=req.headers['authorization'];constcallerusername=req.headers['username'];if(!token){returnres.status(401).send({message:'No token provided'});}try{constdecoded=jwt.verify(token,callerusername)console.log('decoded : ',decoded)constcheck=awaitdb.query('SELECT * FROM users WHERE username=$1',[decoded.username])console.log('check ',check)if(check.rows[0].role!='admin'){returnres.status(401).send({message:'you are not an admin'});}}catch(err){returnres.status(401).send({message:'Invalid token'});}/* ------------------ check if caller is admin ---- end --------------------- */
Erläuterungen:
In den Zeilen 3 und 4 lesen wir den token und den callerusername aus, die beide im header des requests gesendet werden.
Wenn im header unter dem Schlüssel authorization kein token gesendet wird, wird die Anfrage mit einer 401-Response abgelehnt (Zeilen 6-8).
Wenn ein token verfügbar ist, wird dieser mithilfe der Funktion verify() des jsonwebtoken-Paketes dekodiert. Dabei wird der callerusername als secret verwendet (wie beim Signieren).
Nun fragen wir in der Datenbank an, ob es sich bei dem callerusername um einen admin handelt. Das steht zwar auch im payload des token, kann sich aber in der Zwischenzeit theoretisch geändert haben.
Wenn es sich nicht um einen admin handelt, wird die Anfrage ebenfalls mit einer 401-Response abgelehnt (Zeilen 15-17).
Falls die verify()-Funktion einen Fehler geworfen hat, z.B. weil das secret nicht korrekt war, wird ebenfalls die Anfrage mit einer 401-Response abgelehnt.
Erst, wenn diese Prüfungen alle erfolgreich absolviert werden, kann der nachfolgende Code ausgeführt werden. Wir werden die oben gezeigt Implementierung nun in jedem Endpunkt voranstellen, der admin-Rechte zur Anbfrage benötigt.
// get all usersrouter.get('/',async(req,res)=>{/* ------------------ check if caller is admin ---- start --------------------- */console.log('request headers: ',req.headers)consttoken=req.headers['authorization'];constcallerusername=req.headers['username'];if(!token){returnres.status(401).send({message:'No token provided'});}try{constdecoded=jwt.verify(token,callerusername)console.log('decoded : ',decoded)constcheck=awaitdb.query('SELECT * FROM users WHERE username=$1',[decoded.username])console.log('check ',check)if(check.rows[0].role!='admin'){returnres.status(401).send({message:'you are not an admin'});}}catch(err){returnres.status(401).send({message:'Invalid token'});}/* ------------------ check if caller is admin ---- end --------------------- *//* ------- if caller is admin, then do the following --------------------------- */constquery=`SELECT * FROM users `;try{constresult=awaitdb.query(query)console.log(res)res.status(200)res.send(result.rows);}catch(err){console.log(err.stack)}});
Abzüglich der Überprüfung des JWT sieht der Endpunkt also so aus, wie wir ihn bereits kennen. Das gilt im Prinzip auch für die folgenden Endpunkte.
Im header wird hier wieder unter authorization der JWT und unter username der callerusername erwartet. Als Parameter wird der username aufgerufen, dessen Daten gesendet werden, falls er existiert.
// get one user bei usernamerouter.get('/:username',async(req,res)=>{/* ------------------ check if caller is admin ---- start --------------------- */console.log('request headers: ',req.headers)consttoken=req.headers['authorization'];constcallerusername=req.headers['username'];if(!token){returnres.status(401).send({message:'No token provided'});}try{constdecoded=jwt.verify(token,callerusername)console.log('decoded : ',decoded)constcheck=awaitdb.query('SELECT * FROM users WHERE username=$1',[decoded.username])console.log('check ',check)if(check.rows[0].role!='admin'){returnres.status(401).send({message:'you are not an admin'});}}catch(err){returnres.status(401).send({message:'Invalid token'});}/* ------------------ check if caller is admin ---- end --------------------- *//* ------- if caller is admin, then do the following --------------------------- */constquery=`SELECT * FROM users WHERE username = $1`;try{constusername=req.params.username;constresult=awaitdb.query(query,[username])if(result.rowCount>0){res.status(200)res.send(result.rows[0]);}else{res.status(404)res.send({message:`user with username ${username} does not exist`});}}catch(err){console.log(err.stack)}});
Im body des Requests wird der username, das oldpassword und das newpassword erwartet. Das oldpassword wird mittels bcrypt.compare() mit dem in der Datenbank gespeicherten Passwort verglichen. Bei Erfolg, wird das gehashte newpassword (bcrypt.hash()) in die Datenbank anstelle des alten Passwortes gespeichert. Der Endpunkt benötigt keine Autorisierung.
Im header wird hier wieder unter authorization der JWT und unter username der callerusername erwartet, d.h. es handelt sich um einen geschützten Endpunkt. Im body des Requests wird der username der Nutzerin erwartet, deren Rolle auf admin gesetzt werden soll. Dies geschieht, ohne dass geprüft wird, ob die Rolle vorher user war.
router.put('/setadmin',async(req,res)=>{/* ------------------ check if caller is admin ---- start --------------------- */console.log('request headers: ',req.headers)consttoken=req.headers['authorization'];constcallerusername=req.headers['username'];if(!token){returnres.status(401).send({message:'No token provided'});}try{constdecoded=jwt.verify(token,callerusername)console.log('decoded : ',decoded)constcheck=awaitdb.query('SELECT * FROM users WHERE username=$1',[decoded.username])console.log('check ',check)if(check.rows[0].role!='admin'){returnres.status(401).send({message:'you are not an admin'});}}catch(err){returnres.status(401).send({message:'Invalid token'});}/* ------------------ check if caller is admin ---- end --------------------- *//* ------- if caller is admin, then do the following --------------------------- */letusername=req.body.username;constupdatequery=`UPDATE users SET role='admin' WHERE username=$1 RETURNING *;`;constupdateresult=awaitdb.query(updatequery,[username]);console.log('updateresult : ',updateresult)res.status(200)res.send(updateresult.rows[0])})
Im header wird hier wieder unter authorization der JWT und unter username der callerusername erwartet, d.h. es handelt sich um einen geschützten Endpunkt. Die id der zu löschenden Nutzerin wird als Parameter der Route übergeben.
// delete one user via idrouter.delete('/:id',async(req,res)=>{/* ------------------ check if caller is admin ---- start --------------------- */console.log('request headers: ',req.headers)consttoken=req.headers['authorization'];constcallerusername=req.headers['username'];if(!token){returnres.status(401).send({message:'No token provided'});}try{constdecoded=jwt.verify(token,callerusername)console.log('decoded : ',decoded)constcheck=awaitdb.query('SELECT * FROM users WHERE username=$1',[decoded.username])console.log('check ',check)if(check.rows[0].role!='admin'){returnres.status(401).send({message:'you are not an admin'});}}catch(err){returnres.status(401).send({message:'Invalid token'});}/* ------------------ check if caller is admin ---- end --------------------- *//* ------- if caller is admin, then do the following --------------------------- */try{constid=req.params.id;constquery=`DELETE FROM users WHERE id=$1`;constresult=awaitdb.query(query,[id])console.log(result)if(result.rowCount==1)res.send({message:"User with id="+id+" deleted"});else{res.status(404)res.send({message:"No user found with id="+id});}}catch(err){console.log(err.stack)}});
Um die abgesicherten Endpunkte in Postman zu testen, müssen wir die benötigten Informationen im Header an das request-Objekt übergeben. Die folgenden Screenshots zeigen, wie das geht.
Wir loggen uns zunächst mit einem admin-Account ein:
In der Response erhalten wir einen token. Diesen benötigen wir, um uns für die abgesicherten Endpunkte zu autorisieren:
Wir wählen in der oberen Request-Hälfte den Menüpunkt Headers aus und geben dort den keyauthorization ein und dafür als Wert den token, den wir in der Response vom Login erhalten haben. Als zweiten Schlüssel geben wir username ein und als Wert den username, für den der token erzeugt wurde. Dieser username wurde bei der Token-Erstellung als secret verwendet. Obere Abbildung zeigt den Aufruf des Endpunktes PUT /user/setadmin. Dafür muss auch noch im Body des Requests der username übergeben werden, für den die Rolle auf admin gewechselt werden soll, z.B.
{"username":"user1"}
Mit den erforderlichen Autorisierungsinformationen im Header können die abgesicherten Endpunkte in Postman getestet werden.
Success
Wir haben ein Backend zur Nutzerverwaltung erstellt. Es stellt die oben beschriebenen Endpunkte zur Verfügung. Es ist leicht um weitere Endpunkte erweiterbar, z.B. ein weiterer Endpunkt, der aus einer Nutzerin in der Rolle admin die Rolle user zuweist. Einige der Endpunkte sind mittels JWT abgesichert, so dass sie nur von als admin eingeloggten Nutzerinnen genutzt werden können. Wir werden nun ein dazu passendes Frontend erstellen, welches das Backend nutzen wird.
Wir erstellen uns mithilfe von Angular eine kleine Webanwendung, die mindestens eine Regstrierungs- und eine Login-Komponente enthält. Wir wollen dieses Mal Material Design anstelle von Bootstrap als CSS-Framework verwenden.
Im Terminal geben wir Folgendes ein:
Terminalbefehl
Beschreibung
ng new frontend
erstellt Projekt frontend (alle Fragen mit Enter beantworten)
Achtung: Das Hinzufügen von Material Design mithilfe von ...@18 ist wichtig (Stand Januar 2025). Es gibt zwar bereits Version 19, aber dort gibt es noch Probleme mit Abhängigkeiten von Paketen. Nach dem Hinzufügen von Material Design sollte im Terminal ungefähr folgende Ausgabe erscheinen:
<mat-sidenav-containerclass="sidenav-container"><mat-sidenav#drawerclass="sidenav"fixedInViewport[attr.role]="(isHandset$|async)?'dialog':'navigation'"[mode]="(isHandset$|async)?'over':'side'"[opened]="(isHandset$|async)===false"><mat-toolbar><ahref="https://freiheit.f4.htw-berlin.de/webtech/guards/#registrierung-und-login-frontend">WebTech</a></mat-toolbar><mat-nav-list><amat-list-item[routerLink]="['']">Home</a><amat-list-item[routerLink]="['register']">Register</a><amat-list-item[routerLink]="['login']">Login</a></mat-nav-list></mat-sidenav><mat-sidenav-content><mat-toolbarcolor="primary"> @if (isHandset$ | async) {
<buttontype="button"aria-label="Toggle sidenav"mat-icon-button(click)="drawer.toggle()"><mat-iconaria-label="Side nav toggle icon">menu</mat-icon></button> }
<span>Nutzerinnenverwaltung</span></mat-toolbar><!-- Add Content Here --><router-outlet></router-outlet></mat-sidenav-content></mat-sidenav-container>
In den Zeilen 8-10 werden die Menüeinträge geändert und die Verweise auf routerLinks geändert. In Zeile 24 wird die Überschrift geändert und in Zeile 27 erscheint der Platzhalter für die per Routing eingebundenen Komponenten. Zeile 6 wurde hier nur optional als Demo geändert, kann natürlich auch bleiben.
Die erforderlichen Anpassungen in der nav.component.ts und der nav.component.css sehen dann so aus:
Die Anwendung sieht nun wie folgt aus (Desktop- und Mobile-Ansicht):
Die Menüeinträge funktionieren und bei der register-Komponente wird bereits ein recht umfangreiches Formular angezeigt (wegen des verwendeten address-form-Schemas).
In dem auth-Service binden wir das Backend an und nutzen bspw. die im Registrierungsformular eingegebenen Daten, um die Nutzerin zu registrieren. Im Gegensatz zu Frontend-Backend-Anbindung, wo wir nur die Fetch-API zum Aufruf der Endpunkte verwendet haben, zeigen wir hier einmal die Verwendung des HTTP Client-Moduls. Wir verwenden hier aber das HTTP-Client-Modul mit der Fetch-API (und nicht mit XMLHttpRequest). Dazu binden wir das HTTP-Client-Modul wie folgt in die app.config.ts ein:
und die submit()-Funktion in der register.component.ts könnte zunächst wie folgt erweitert werden (in der Klasse noch private auth = inject(AuthService) hinzufügen und den Service importieren import { AuthService } from '../shared/auth.service';):
Wenn nun das Registrierungsformular vollständig ausgefüllt wird und weder username noch email bereits in der Datenbank existieren, wird ein neuer Datensatz in der Datenbank angelegt. Die neue Nutzerin ist registriert. Wenn jedoch der username und/oder die email bereits existier(t/en), wird nicht die next-Eigenschaft des Observers aufgerufen, sondern die error-Eigenschaft. Das heißt, entweder gibt es unter next eine response, nämlich den neu angelegten user oder es gibt unter error ein Fehlerobjekt, welches selbst eine error-Eigenschaft hat (mit { message: 'E-Mail test@test.de and/or username user1 already exists'} - test@test.de und user1 hier Beispiele) und dessen Status 401 ist. Die Eigenschaften error, ok, status (und weitere) können aus err ausgelesen werden.
Beachten Sie, dass es im Formular möglich ist, zwischen der Rolle admin und user zu wählen. Das ist aber nur zu Demonstrationszwecken. Der Endpunkt POST /user/register im Backend ignoriert die role-Eigenschaft und registriert die neue Nutzerin stets als user.
import{Component,inject}from'@angular/core';import{ReactiveFormsModule,FormBuilder,Validators,FormControl,FormGroup}from'@angular/forms';import{MatInputModule}from'@angular/material/input';import{MatButtonModule}from'@angular/material/button';import{MatSelectModule}from'@angular/material/select';import{MatRadioModule}from'@angular/material/radio';import{MatCardModule}from'@angular/material/card';import{User}from'../shared/user';import{MatIconModule}from'@angular/material/icon';import{AuthService}from'../shared/auth.service';@Component({selector:'app-register',templateUrl:'./register.component.html',styleUrl:'./register.component.css',standalone:true,imports:[MatIconModule,MatInputModule,MatButtonModule,MatSelectModule,MatRadioModule,MatCardModule,ReactiveFormsModule]})exportclassRegisterComponent{privateauth=inject(AuthService)registerForm=newFormGroup({username:newFormControl('',Validators.required),password:newFormControl('',[Validators.required,Validators.minLength(8)]),password2:newFormControl('',[Validators.required,Validators.minLength(8)]),email:newFormControl('',[Validators.required,Validators.email]),role:newFormControl('',Validators.required)});roles=["admin","user"];hide=true;hide2=true;user!:User;valid():boolean{constcheck=!this.registerForm.controls['username'].hasError('required')&&!this.registerForm.controls['password'].hasError('required')&&!this.registerForm.controls['password'].hasError('minlength')&&!this.registerForm.controls['password2'].hasError('required')&&!this.registerForm.controls['password2'].hasError('minlength')&&!this.registerForm.controls['email'].hasError('required')&&!this.registerForm.controls['email'].hasError('email')&&this.registerForm.value.password==this.registerForm.value.password2;console.log('valid : ',check)returncheck;}differentPassword():boolean{constcheck=this.registerForm.dirty&&this.registerForm.value.password!=this.registerForm.value.password2;if(check){this.registerForm.controls.password2.setErrors({'incorrect':true});}else{this.registerForm.controls.password2.setErrors({'incorrect':false});}console.log('check : ',check)returncheck;}onSubmit():void{constvalues=this.registerForm.value;this.user={username:values.username!,password:values.password!,email:values.email!,role:values.role!};console.log(this.user)if(this.valid()){console.log('eingaben gueltig! Registrierung wird vorgenommen')this.auth.registerUser(this.user).subscribe({next:(response)=>console.log('response',response),error:(err)=>console.log('HttpErrorResponse : ',err),complete:()=>console.log('register completed')});}else{console.log('eingaben ungueltig! Registrierung wird abgelehnt')}}}
Anstatt die Validität der Eingaben in der submit()-Funktion zu prüfen, könnte auch der Button Registrieren solange disabled bleiben, solange valid() ein false zurückgibt ([disabled]="!valid()!").
Derzeit gibt es keine Rückmeldung darüber, ob die neue Nutzerin registriert wurde oder nicht. Wir wollen dazu einen modalen Dialog öffnen, der die entsprechenden Informationen zur Verfügung stellt. Dieser Dialog wird eine Komponente. Da diese Komponente jedoch ausschließlich von der Registrierungskomponente verwendet wird, erstellen wir sie als Kindkomponente der Registrierungskomponente. Wir werden dabei insbesondere die Informationen verwenden, wie wir Datenfluss und Signals eingeführt haben.
Zunächst erstellen wir die (Kind-)Komponente confirm:
nggcregister/confirm
Unter dem Ordner register entsteht ein weiterer Ordner confirm, der die .html, .ts und .css der Kindkomponente confirm enthält. Wir verwenden Dialog von Material Design. Wir gehen vor, wie in Dialog Examples gezeigt.
Wir implementieren nun die Login-Komponente, da wir beim Login ein JWT erhalten, mit dem wir einerseits den Zugriff auf Komponenten in unserem Frontend (über Guards) und andererseits den Zugriff auf Endpunkte in unserem Backend autorisieren wollen.
Die Log-Komponente enthält erneut ein Formular. Das kennen wir bereits aus der Registrierungs-Komponente.
Wir implementieren jetzt noch die submit()-Funktion. Darin rufen wir die loginUser()-Funktion aus dem AuthService auf. Dazu muss der Service per private auth = inject(AuthService) in die login.component.ts injiziert und AuthService importiert werden.
onSubmit():void{constvalues=this.loginForm.value;constusername=values.username!;constpassword=values.password!;constuser={username:username,password:password}console.log('user',user)this.auth.loginUser(user).subscribe({next:(response)=>{console.log('user logged in ',response);},error:(err)=>{console.log('login error',err);},complete:()=>console.log('login completed')})}
In der submit()-Funktion werden die Werte aus dem Formular ausgelesen. Da der Login-Button erst enabled ist, wenn die Eingaben valide sind, enthlten die Eingabefelder sicher Werte. Aus den Werten wird ein user-Objekt zusammengesetzt und der loginUser()-Funktion aus dem Service übergeben. Wir fangen in der error-Eigenschaft die mögliche Response ab, dass username und/oder password nicht korrekt sind.
Wir überlegen uns nun, wie wir mit der erfolgreichen Response umgehen (also damit, dass eine Nutzerin nun erfolgreich eingeloggt ist). Wir wollen sowohl die Informationen über die Nutzerin speichern als auch insbesondere den JWToken, der zurückgegeben wird. Dazu erweitern wir den AuthService.
user und token sind WritableSignals. Sie werden mithilfe von signal() initialisiert, wobai man den initialen Wert als Parameter übergibt.
loggedIn und isAdmin sind ebenfalls Signals, werden aber aus dem Wert des Signalsuser berechnet. Immer, wenn user einen neuen Wert annimmt, berechnet sich der Wert von loggedIn und isAdmin neu.
In den Methoden setUser() und unsetUser() werden die Werte der Signalsuser und token mithilfe der set()-Funktion neu gesetzt.
Wir werden nun nach einem erfolgreichen Login die Werte der Signals mithilfe der setUser()-Funktion setzen:
Wenn das Login erfolgreich war, könnte direkt die home-Komponente aufgerufen werden. Ist das Login nicht erfolgreich, wird bei der Login-Komponente verblieben. Es erfolgt nur eine Nachricht auf der Konsole - hier könnte z.B. auch ein modaler Dialog erscheinen, wie bei der Registrierung.
Wir zeigen den Nutzen und die Nutzung von Signals zunächst an einem einfachen Beispiel anhand unserer NavComponent. Derzeit zeigt diese als Menüeintrag stets Login an, unabhängig davon, ob eine Nutzerin eingeloggt ist oder nicht. Sinnvoller wäre es hier, dass sich dieser Eintrag auf Logout ändert, sobald eine Nutzerin eingeloggt ist. Über diesen Eintrag soll dann jederzeit ein Logout möglich sein.
Außerdem könnte solange rechts oben ein Login-Icon angezeigt werden, solange niemand eingeloogt ist und sobald eine Nutzerin eingeloggt ist, erscheint rechts oben ihr username und das Logout-Icon.
In der nav.component.ts definieren wir selbst ein SignalloggedIn. Der Wert dieses Signals berechnet sich aus dem Wert des SignalsloggedIn aus dem AuthService. Dieses Signal hätte hier auch direkt verwendet werden können, aber wir wollten einmal die computed()-Funktion von Signals zeigen.
In der nav.component.html lesen wir den Wert des Signals mithilfe von loggedIn() aus. Ist er true, wird Logout angezeigt, ist er false, zeigt das Menü Login.
Für Logout wurde eine Funktion logout() definiert, die user und token im AuthService zurücksetzt und die auf die Login-Seite navigiert. Die login()-Funktion leitet einfach nur auf die Login-Seite weiter.
Mithilfe von Guards können wir festlegen, dass Komponenten z.B. nur dann aufgerufen werden können, wenn man eingeloggt ist (aber nicht, wenn man nicht eingeloggt ist) oder wenn man z.B. als admin eingeloggt (und nicht nur als user) eingeloggt ist. Wir werden hier demonstrieren, wie man solche Guards implementiert und verwendet. Dazu erstellen wir uns zunächst eine weitere Komponente. Die Komponente userlist soll alle user aus der Datenbank auflisten (als Tabelle). Diese Komponente soll nur aufgerufen werden können, wenn man als admin eingelogged ist. Außerdem werden wir den Aufruf der HomeComponent nur für den Fall erlauben, dass man eingelogged ist.
Die userlist-Komponente erstellen wir mithilfe des Material-Design-Schemas table:
nggenerate@angular/material:tableuserlist
Für das vereinfachte Beispiel hier haben wir jedoch die z.B. die Paginierung weggelassen. Viele Beispiele zu Tabellen mit Sortierung, Filterung, Paginierung usw. finden Sie hier.
import{DataSource}from'@angular/cdk/collections';import{MatPaginator}from'@angular/material/paginator';import{MatSort}from'@angular/material/sort';import{map}from'rxjs/operators';import{Observable,ofasobservableOf,merge}from'rxjs';import{User}from'../shared/user';import{AuthService}from'../shared/auth.service';import{inject}from'@angular/core';/** * Data source for the Userlist view. This class should * encapsulate all logic for fetching and manipulating the displayed data * (including sorting, pagination, and filtering). */exportclassUserlistDataSourceextendsDataSource<User>{privateauth=inject(AuthService)data!:User[];paginator:MatPaginator|undefined;sort:MatSort|undefined;constructor(){super();this.auth.getAllUsers().subscribe({next:(response)=>{this.data=response;console.log('this.users',this.data)},error:(err)=>console.log('error',err),complete:()=>console.log('getAllUsers() complete')});}/** * Connect this data source to the table. The table will only update when * the returned stream emits new items. * @returns A stream of the items to be rendered. */connect():Observable<User[]>{if(this.paginator&&this.sort){// Combine everything that affects the rendered data into one update// stream for the data-table to consume.returnmerge(observableOf(this.data),this.paginator.page,this.sort.sortChange).pipe(map(()=>{returnthis.getPagedData(this.getSortedData([...this.data]));}));}else{throwError('Please set the paginator and sort on the data source before connecting.');}}/** * Called when the table is being destroyed. Use this function, to clean up * any open connections or free any held resources that were set up during connect. */disconnect():void{}/** * Paginate the data (client-side). If you're using server-side pagination, * this would be replaced by requesting the appropriate data from the server. */privategetPagedData(data:User[]):User[]{if(this.paginator){conststartIndex=this.paginator.pageIndex*this.paginator.pageSize;returndata.splice(startIndex,this.paginator.pageSize);}else{returndata;}}/** * Sort the data (client-side). If you're using server-side sorting, * this would be replaced by requesting the appropriate data from the server. */privategetSortedData(data:User[]):User[]{if(!this.sort||!this.sort.active||this.sort.direction===''){returndata;}returndata.sort((a,b)=>{constisAsc=this.sort?.direction==='asc';switch(this.sort?.active){case'name':returncompare(a.username,b.username,isAsc);case'role':returncompare(a.role,b.role,isAsc);default:return0;}});}}/** Simple sort comparator for example ID/Name columns (for client-side sorting). */functioncompare(a:string|number,b:string|number,isAsc:boolean):number{return(a<b?-1:1)*(isAsc?1:-1);}
import{AfterViewInit,Component,ViewChild}from'@angular/core';import{MatTableModule,MatTable}from'@angular/material/table';import{MatPaginatorModule,MatPaginator}from'@angular/material/paginator';import{MatSortModule,MatSort}from'@angular/material/sort';import{UserlistDataSource}from'./userlist-datasource';import{User}from'../shared/user';@Component({selector:'app-userlist',templateUrl:'./userlist.component.html',styleUrl:'./userlist.component.css',standalone:true,imports:[MatTableModule,MatPaginatorModule,MatSortModule,]})exportclassUserlistComponentimplementsAfterViewInit{@ViewChild(MatPaginator)paginator!:MatPaginator;@ViewChild(MatSort)sort!:MatSort;@ViewChild(MatTable)table!:MatTable<User>;dataSource=newUserlistDataSource();/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */displayedColumns=['username','email','role'];ngAfterViewInit():void{this.dataSource.sort=this.sort;this.dataSource.paginator=this.paginator;this.table.dataSource=this.dataSource;}}
In Routen absichern mit Guards haben wir bereits die Grundidee von Guards vorgestellt. Wir wollen diese hier anwenden und beschränken uns dabei auf den Guard-TypCanActivate. Wir wollen sicherstellen, dass die HomeComponent nur aktiviert werden kann, wenn man eingeloggt ist und die UserlistComponent nur dann, wenn man als admin eingelogged ist, um das Prinzip zu verdeutlichen. Wir erstellen uns also einen CanActivate-Guard (im Ordner shared):
Dieser Guard stellt zwei Funktionen zur Verfügung: authguardLogin und authguardAdmin.
authguardLogin gibt bei Aufruf ein true zurück, wenn eine Nutzerin eingeloggt ist (loggedIn() aus dem AuthService). Wenn niemand eingeloggt ist, (wenn also loggedIn() ein false zurückgibt), dann wird die aktuelle Route nach /login umgeleitet.
authguardAdmin gibt bei Aufruf ein true zurück, wenn eine Nutzerin die Rolle admin hat (isAdmin() aus dem AuthService). Wenn nicht, (wenn also isAdmin() ein false zurückgibt), dann wird die aktuelle Route nach /login umgeleitet.
Wenn wir nun die Anwendung öffnen, dann kommen wir gar nicht auf HomeComponent, sondern werden stets zur LoginComponent geleitet. Erst wenn wir eingeloggt sind, ist die HomeComponent erreichbar. Die UserListComponent ist nur dann aufrufbar, wenn wir in der Rolle admin eingeloggt sind. Ansonsten würden wir auch bei Aufruf /users an die /login-Route weitergeleitet werden.
Wir passen die Menüeinträge in der NavComponent nun noch so an, dass Home im Menü erscheint, sobald wir eingeloggt sind (egal, ob als admin oder als user). Die UserList soll stattdessen nur aufrufbar sein, wenn wir als admin eingeloggt sind.
Die HomeComponent haben wir ganz schlicht gehalten und dient nur dem Zeigen des Zugriffs sowohl eingeloggt als user als auch eingeloggt als admin. Hier soll nur das Prinzip gezeigt werden. Für ide HomeComponent gilt:
ist (nur) aufrufbar, wenn man eingeloggt ist (realisiert per GuardauthguardLogin()),
import{Component,computed,inject,OnInit,Signal}from'@angular/core';import{MatCardModule}from'@angular/material/card';import{MatChipsModule}from'@angular/material/chips';import{AuthService}from'../shared/auth.service';import{RouterLink}from'@angular/router';@Component({selector:'app-home',standalone:true,imports:[MatCardModule,MatChipsModule,RouterLink],templateUrl:'./home.component.html',styleUrl:'./home.component.css'})exportclassHomeComponentimplementsOnInit{privateauth=inject(AuthService);isAdmin:Signal<boolean>=computed(()=>this.auth.isAdmin());ngOnInit():void{// braucht man nicht - nur zum Debuggenconsole.log('isAdmin : ',this.isAdmin())}}
<divclass="space"><mat-cardclass="example-card"appearance="outlined"><mat-card-header><mat-card-title>Home-Komponente</mat-card-title></mat-card-header><mat-card-content><p>Hier ist Ihre eigentliche Anwendung. Die Home-Komponente kann nur aufgerufen werden,
wenn man eingeloggt ist (egal, ob als <strong>admin</strong> oder <strong>user</strong>.)</p><p>Sollten Sie <strong>admin</strong> sein, sehen Sie unten sogar einen Button, der
Sie zur <strong>UserList</strong>-Komponente weiterleitet.</p></mat-card-content> @if(isAdmin()) {
<mat-card-footerclass="example-card-footer"><mat-chip-setaria-label="link zu login"><mat-chip><a[routerLink]="['/users']">UserList-Komponente</a></mat-chip></mat-chip-set></mat-card-footer> }
</mat-card></div>
Wir haben eine (sehr einfache) Nutzerverwaltung implementiert. Eine Nutzerin kann sich registrieren und einloggen. Die Registrierungsdaten werden in der Datenbank gespeichert. Das Passwort wird verschlüsselt abgelegt. Jeder Nutzerin kann eine Rolle zugewiesen werden. Abhängig davon, ob jemand eingeloggt ist bzw. in welcher Rolle, sind die Komponenten unterschiedlich erreichbar. Dies wurde mit Guards realisiert. Für das Layout wurde Angular Material verwendet. Die Konzepte für eine Dialoggestaltung, für die Erweitereung und Anbindung des Backends sowie für eine Weitereleitung auf eine andere Komponente wurden jedoch alle exemplarisch gezeigt.