
In den meisten Apps gibt es eine Funktionalität, die unerlässlich ist: die Authentifizierungs- und Nutzerverwaltungs-Funktionalität.
Klar, dafür nutzen die meisten (ich auch) einfach einen Auth Provider wie Firebase, Okta oder Amazon Cognito. Diese locken mit großem, kostenlosem Kontingent und einfacher Implementierung. Eingerichtet sind sie schnell, und wenn meine App nicht viral geht, zahle ich nichts. Wenn doch, nehme ich die Kosten sowieso wieder ein. So denken viele, ich auch.
Trotzdem ist es jedes Mal ein Kompromiss. Irgendwie unbefriedigend zu wissen, dass man bei vielen Nutzern tausende Euro nur für die Authentifizierung zahlt – und später wechseln kann man so einfach nicht mehr. Ein weiterer Dorn im Auge ist der Datenschutz. Die sensiblen Nutzer- und Logindaten liegen auf Servern der Auth Provider, die meist aus den USA kommen. Und wir wollen uns in Europa ja unabhängiger machen, habe ich gehört…
Vor allem deshalb, und weil ich denke, dass es ein gutes Lernprojekt ist, habe ich mich entschieden, ein eigenes Auth-System zu entwickeln. Das habe ich in Spring Boot umgesetzt. Meine Erkenntnisse und die Implementierung teile ich hier. Der Service ist dafür gedacht, in einer Microservice-Architektur eingebunden zu werden.
Stateless Auth mit JWT
Der Trend der letzten Jahre ging im Backend weg vom großen Monolithen hin zu mehreren kleinen Microservices. Diese Entwicklung hat auch die Authentifizierung verändert. Früher, in der Zeit der Monolithen, hatte man Stateful-Authentication-Systeme. Der Client authentifiziert sich, der Server speichert diese Information in einer Session, und so konnte der Client dann auf Ressourcen zugreifen.
Bei einer Microservice-Architektur funktioniert das nicht mehr so einfach. Der Fokus hat sich daher verschoben hinzu Stateless-Authentifizierung: Der Authentifizierungs-Service (Auth Service) speichert keine Sessions. Bei jedem Request schickt der Client seine Authentifizierungsinformationen (Access Token) mit.
Die Stateless-Authentifizierung baut auf JSON Web Tokens (JWTs) auf. Das sind signierte Strings, die vom Auth Service ausgestellt werden. Der Client speichert sie und schickt sie bei jedem Request mit. JWTs sind nicht verschlüsselt, sondern nur Base64-kodiert. Der Inhalt kann von jedem gelesen werden. Die Signatur stellt lediglich sicher, dass der Inhalt nicht manipuliert wurde.

Der Client schickt eine Anfrage mit E-Mail und Passwort an den Auth Service. Dieser prüft die Daten und gibt bei Erfolg zwei Tokens zurück: einen Access Token und einen Refresh Token. Den Access Token schickt der Client bei jeder Anfrage an andere Backend-Services (Resource Server) mit. Da der Client, beispielsweise eine Mobile App nicht sicher ist und Angreifer die Tokens potenziell stehlen können, ist der Access Token nur kurz gültig (15 Minuten). Dafür gibt es den Refresh Token mit längerer Gültigkeit. Dieser kann serverseitig zurückgezogen werden und wird genutzt, um neue Access Tokens zu erhalten.
Datenbank Schema
Bevor ich auf die einzelnen Funktionalitäten genauer eingehe, hier das Datenbank-Schema. Es besteht aus vier Tabellen.

Alle Token-Tabellen haben eine n:1-Beziehung zur users-Tabelle über eine user_id.
Ich orientiere mich bei der Implementierung an den Flows (Registrierung, Login etc.) von Firebase und anderen Auth Providern. Nutzer sollen sich klassisch über E-Mail und Passwort registrieren können, aber auch über ihren Google-Account. Hier konzentriere ich mich auf Google – andere Provider wie Apple können später ähnlich hinzugefügt werden. Auf fortgeschrittene Konzepte wie 2-Faktor-Authentifizierung gehe ich nicht ein. Diese könnten später bei Bedarf umgesetzt werden.
Registrierung mit E-Mail und Passwort

Die Registrierung mit einer E-Mail und einem Passwort funktioniert folgendermaßen:
E-Mail-Verifizierung

Logout

Login

Der Access Token bleibt bis zu seinem Ablauf (15 Minuten) gültig – das ist bei Stateless-JWT-Systemen üblich. Bei sicherheitskritischen Anwendungen könnte man eine Token-Blacklist implementieren. Darauf wurde hier verzichtet.

Passwort zurücksetzen

Login mit Google

Spring Security ist der Standard für Sicherheit in Spring-Boot-Anwendungen.
Die Komponente FilterChainProxy verwaltet SecurityFilterChain-Instanzen. Jeder Request durchläuft eine Liste von Filtern, die jeweils eigene Verantwortungen haben.
Meine Konfiguration:
@Bean
public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/register", "/login", "/refresh", "/verify-email",
"/google", "/request-password-reset", "/reset-password").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}Die öffentlichen Endpunkte (Registrierung, Login, etc.) sind für alle erreichbar. Alle anderen Endpunkte erfordern Authentifizierung. Das Session-Management ist auf Stateless gesetzt.
Der Authentication Provider ist als DaoAuthenticationProvider implementiert, d.h. die Nutzerdaten liegen in meiner eigenen SQL-Datenbank. Dafür definiere ich einen UserService, der UserDetailsService (von Spring Security) implementiert.
Der JwtAuthenticationFilter ist das Herzstück der Filterkette. Er extrahiert das JWT aus dem Header, validiert es und authentifiziert den Request. Bei ungültigem Token wirft Spring Security einen 401-Fehler. DerJwtAuthenticationFilter kommuniziert mit dem JWTSerice, der die Funktionalität der JWTs kapselt.
JWT Service
Ein JWT besteht aus drei Teilen:
Ich nutze die JJWT-Bibliothek für die Implementierung von JWTs in Spring Boot. Da ich den Auth Service in Microservice-Architekturen einsetzen will, sollen alle Resource Server die JWTs selbst validieren können, ohne jeden Request durch den Auth Service zu schicken. Die Resource Server haben daher auch eine Abhängigkeit zu der JJWT-Bibliothek.
Dafür stellt der Auth Service einen Public Key bereit. Er signiert JWTs mit dem Private Key, die Resource Server validieren mit dem Public Key. Die Keys generiere ich auf der Kommandozeile mit openssl:
openssl genrsa -out private.key 2048
openssl rsa -in private.key -pubout -out public.keyDer JwtService kapselt die JWT-Funktionalität. Folgender Code zeigt die wichtigsten Funktionen:
@Service
public class JwtService {
private final long accessTokenExpiration;
private final long refreshTokenExpiration;
private final PublicKey publicKey;
private final PrivateKey signingKey;
public JwtService(...) {...}
public String generateAccessToken(UserDetails userDetails) {
return generateToken(userDetails, accessTokenExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return generateToken(userDetails, refreshTokenExpiration);
}
private String generateToken(UserDetails userDetails, long expiration) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(signingKey)
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername())
&& extractClaim(token, Claims::getExpiration).after(new Date());
}
}Die Abhängigkeiten werden im Konstruktor übergeben. Einige Hilfsfunktionen sind in diesem Code-Beispiel nicht gezeigt. Dieses zeigt lediglich die Implementierung der wichtigsten Methoden: generateAccessToken, generateRefreshToken, generateToken, isTokenValid.
Für die Authentifizierung mit Third Party Provider, wie Google, gibt es einen speziellen Authentifizierungsflow. Wie dieser aussieht wurde bereits gezeigt. In der Spring Boot Anwendung implementiere ich speziell für die Authentifizierung mit Google einen eigenen Service, den GoogleAuthService.
@Service
public class GoogleAuthService {
private final UserRepository userRepository;
private final GoogleIdTokenVerifier verifier;
@Transactional
public User authenticate(String googleIdTokenString) {
GoogleIdToken idToken;
try {
idToken = verifier.verify(googleIdTokenString);
} catch (GeneralSecurityException | IOException e) {
throw new GoogleAuthenticationException("Fehler bei der Token-Verifizierung", e);
}
if (idToken == null) {
throw new InvalidGoogleTokenException();
}
GoogleIdToken.Payload payload = idToken.getPayload();
String email = payload.getEmail();
if (!payload.getEmailVerified()) {
throw new GoogleEmailNotVerifiedException();
}
return userRepository.getByEmail(email)
.orElseGet(() -> userRepository.save(User.builder()
.email(email)
.provider("google")
.providerId(payload.getSubject())
.enabled(true)
.emailVerified(true)
.build()));
}
}Der GoogleIdTokenVerifier stammt aus der Google API Client Library. Er verifiziert das Token bei Google. Ich fange spezifische Exceptions ab und wrappe sie in eine eigene GoogleAuthenticationException. Nur Nutzer mit verifizierter Google-E-Mail dürfen sich authentifizieren. Der Service sucht den Nutzer in der Datenbank oder legt ihn neu an.
Die Abhängigkeit in Gradle:
dependencies {
implementation 'com.google.api-client:google-api-client:2.7.2'
}Konfiguration der Client-IDs in der application.yaml:
google:
client-ids:
- token1.apps.googleusercontent.com # web client
- token2.apps.googleusercontent.com # iOS
- token3.apps.googleusercontent.com # AndroidIch habe einen simplen Auth Service implementiert. Clients können sich registrieren, einloggen und erhalten Tokens für den Zugriff auf andere Services in der Microservice-Architektur.
Ein verbreiteter Standard ist OAuth und OpenID Connect (OIDC). Spring Security bietet umfassenden Support für Authorization Server. Ich habe mich allerdings dagegen entschieden, da ich erstmal einen einfachen Login-Flow für 1-2 Clients bauen wollte. Dafür reicht mein Setup.
Beim Google-Login nutze ich übrigens trotzdem den OAuth-Flow – nur eben als Client, nicht als Server.
Wenn meine App stark wächst und sich Nutzer bei anderen Apps über meinen Account authentifizieren wollen, oder wenn ich viel Tooling baue (CLI-Tool, viele Clients), würde ein eigener OAuth Authorization Server Sinn machen. Aktuell wäre das Overkill. Außerdem würde die User Experience leiden: Das Login-Formular läge nicht mehr in der App, sondern der Nutzer müsste sich im Browser anmelden.
Firebase Auth, an dem ich mich orientiere, macht das übrigens auch nicht. Daher erstmal diese Lösung – erweitern kann ich diese später immer noch.
Im nächsten Artikel zeige ich, wie ein Client aussehen muss, um den Auth Service richtig zu nutzen. Dafür implementiere ich einen Flutter-Client.