1. Einleitung
Dieses Projekt implementiert ein Authentifizierungssystem ohne den Einsatz externer Identity Provider wie Keycloak.
1.1. Authentication Filter
1.2. 2.1 Base64AuthenticationParser
Für zukünftige Erweiterungen – z. B. Login per HTTP Basic Auth – ist ein Parser vorbereitet. Dieser dekodiert den Authorization
-Header und extrahiert Benutzername und Passwort.
@ApplicationScoped
public class Base64AuthenticationParser {
public static final Pattern basicAuthenticationPattern = Pattern.compile("Basic (.*)");
// Basic dXNlcjpwYXNzd2Q=
public Credentials parseAuthenticationHeader(String header) {
Credentials credentials = null;
if (header != null) {
var matcher = basicAuthenticationPattern.matcher(header);
boolean matchFound = matcher.find();
if (matchFound) {
var encodedCredentials = matcher.group(1);
var decodedCredentials = new String(Base64.getDecoder().decode(encodedCredentials));
Log.info(decodedCredentials);
var usernameAndPassword = decodedCredentials.split(":");
credentials = new Credentials(usernameAndPassword[0], usernameAndPassword[1]);
}
}
return credentials;
}
}
1.3. 2.2 Credentials
Der Benutzername und das Passwort werden in einem kompakten record
gespeichert, das als Transferobjekt dient.
public record Credentials(String username, String password) {
}
1.4. 2.3 SessionProperties
In dieser Klasse wird konfiguriert, wie lange eine Session gültig ist. Die Angabe erfolgt z. B. in der application.properties
:
session.validhours=2
@ConfigurationProperties("session")
public class SessionProperties {
public int validhours;
}
2. 5. AllowAll – Endpunkte ohne Authentifizierung
Die Annotation @AllowAll
kann auf Klassen oder Methoden gesetzt werden, um sie von der Authentifizierung auszunehmen. Das wird im AuthenticationFilter
erkannt und sorgt dafür, dass die Anfrage ohne Prüfung durchgeht.
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface AllowAll {
}
3. AuthenticationFilter – Sessionbasierte Authentifizierung
3.1. Provider-Registrierung und Priorität
Dieser Filter wird als JAX-RS @Provider
registriert und mit AUTHENTICATION
priorisiert, sodass er vor allen anderen Filtern ausgeführt wird.
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {
3.2. Abhängigkeiten injizieren
Hier werden Repositories und Konfigurationsklassen injiziert. Diese werden benötigt, um die Session zu prüfen und Benutzerdaten abzurufen.
@Inject
Base64AuthenticationParser base64AuthenticationParser;
@Inject
UserSessionRepository userSessionRepo;
@Inject
UserRepository userRepo;
@Context
ResourceInfo resourceInfo;
@Inject
SessionProperties sessionProperties;
public static final String CREDENTIALS = AuthenticationFilter.class.getSimpleName() + "_CREDENTIALS";
3.3. Einstieg in den Filter
Die filter(…)
Methode wird bei jeder HTTP-Anfrage ausgeführt. Zunächst wird geprüft, ob die Resource mit @AllowAll
gekennzeichnet ist.
@Override
public void filter(ContainerRequestContext ctx) throws IOException {
var annotation = resourceInfo.getResourceClass().getAnnotation(AllowAll.class);
Log.info("Container Request Filter for authentication - Wer bin ich?");
if (annotation == null) {
Cookie cookie = ctx.getCookies().get("Session");
Log.infof("Session: %s", cookie);
3.4. Session-Cookie prüfen
Es wird versucht, den Session
-Cookie auszulesen. Wenn kein Cookie vorhanden ist, wird der Zugriff verweigert.
var sessionId = cookie.getValue();
Log.infof("Getvalue: %s", sessionId);
if (cookie == null) {
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
return;
}
3.5. Session-Objekt laden und prüfen
Die Session-ID aus dem Cookie wird in der Datenbank gesucht. Falls keine gültige Session existiert, wird der Zugriff blockiert.
Optional<UserSession> userSessionGotten = userSessionRepo.findByIdOptional(UUID.fromString(sessionGot));
if (userSessionGotten.isEmpty()) {
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
return;
}
userSessionGotten.ifPresent(s -> {
ctx.setProperty(CREDENTIALS, s.getUserId().getId());
});
3.6. Ablaufzeit der Session prüfen
Die Session ist nur für eine konfigurierbare Anzahl an Stunden gültig. Dieser Teil des Codes ist aktuell auskommentiert.
User user = userRepo.findById(userSessionGotten.get().getUserId().getId());
boolean isValid = userSessionGotten.get().getTimestamp()
.isAfter(LocalDateTime.now().minusHours(sessionProperties.validhours));
/*if (isValid) {
ctx.setProperty(CREDENTIALS, new Credentials(user.getName(), user.getPassword()));
} else {
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
}*/
3.7. Zugriff ohne Authentifizierung erlauben
Falls @AllowAll
gesetzt ist, wird die Authentifizierung übersprungen.
} else {
Log.info("@PermitAll detected");
}
4. AuthorizationFilter – Rollenbasierte Zugriffskontrolle
4.1. Provider-Registrierung
Der AuthorizationFilter
wird bei jeder Anfrage nach dem Authentifizierungsfilter ausgeführt und ist für die Rechteprüfung zuständig.
@Provider
@Priority(Priorities.AUTHORIZATION)
public class AuthorizationFilter implements ContainerRequestFilter {
4.2. Repository-Injektion
Das UserRepository
wird verwendet, um Benutzerdaten anhand der ID aus der Session abzurufen.
@Inject
UserRepository userRepository;
4.3. Start des Filters
Die filter()
Methode wird bei jeder Anfrage ausgeführt. Hier beginnt die Autorisierungslogik.
@Override
public void filter(ContainerRequestContext ctx) throws IOException {
Log.info("Container Request Filter for authorization - Was darf ich?");
4.4. Benutzer-ID aus Session abrufen
Die Benutzer-ID wurde im AuthenticationFilter
gesetzt und wird hier verwendet, um den Benutzer zu identifizieren.
var userId = (Long) ctx.getProperty(AuthenticationFilter.CREDENTIALS);
Log.infof("test %s", userId);
4.5. Benutzer laden und Zugriff prüfen
Mit der Benutzer-ID wird der Benutzer geladen. In einer erweiterten Version könnte hier die Rolle geprüft werden und ggf. der Zugriff verweigert werden.
if (userId != null) {
var user = userRepository.findById(userId);
Log.infof("Der User ist %s %s", user.getName(), user.getPassword());
// if(credentials == null) {
// ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
// }
}
5. Login-Flow und Session-Erstellung
5.1. REST-Endpunkt für Login
Die Klasse LoginResource
enthält den REST-Endpunkt für den Login. Hier wird überprüft, ob Benutzername und Passwort gültig sind. Bei Erfolg wird eine neue Session erstellt und als Set-Cookie
zurückgegeben.
@Path("/login")
@AllowAll
public class LoginResource {
5.2. Login-Methode
Hier erfolgt die eigentliche Authentifizierung und Session-Erstellung.
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response login(Credentials credentials) {
if (credentials == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
TypedQuery<User> query = em.createQuery("SELECT u FROM User u WHERE u.name = :username " +
"AND u.password = :password", User.class);
query.setParameter("username", credentials.username());
query.setParameter("password", credentials.password());
User user;
try {
user = query.getSingleResult();
} catch (Exception e) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
Log.info("Login- I was here");
UserSession userSession = loginRepository.login(user);
Log.infof("The created user: %s %s", user.getId(), userSession.getToken());
return Response
.ok()
.header("Set-Cookie", String.format("Session=%s", userSession.getToken()))
.build();
}
5.3. LoginRepository
Die Methode login()
erstellt eine neue UserSession und speichert sie in der Datenbank.
@ApplicationScoped
public class LoginRepository {
@Inject
EntityManager em;
@Transactional
public UserSession login(User user) {
UserSession userSession = new UserSession(user);
em.persist(userSession);
return userSession;
}
}
5.4. User-Entity
Die Benutzer werden in der Entity User
verwaltet. Diese enthält Benutzername und Passwort.
@Entity
@Table(name = "A_USER")
public class User extends PanacheEntityBase {
@Id
@GeneratedValue (strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
private String name;
private String password;
public User(String name, String password) {
this.name = name;
this.password = password;
}
public User() {
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
5.5. UserSession-Entity
Sobald ein Login erfolgreich war, wird eine UserSession
erstellt, die die Benutzer-ID sowie einen Zeitstempel enthält. Die Session-ID ist ein zufällig generierter UUID-Token, der im Cookie gespeichert wird.
@Entity
@Table(name = "A_SESSION")
public class UserSession extends PanacheEntityBase {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID token;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User userId;
@CreationTimestamp
private LocalDateTime timestamp;
public UserSession(User userId) {
this.userId = userId;
}
public UserSession() {
}
public UUID getToken() {
return token;
}
public User getUserId() {
return userId;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setUserId(User userId) {
this.userId = userId;
}
}
6. Integrationstests
Zur Verifikation des Logins und des Zugriffs auf geschützte Endpunkte wurden Integrationstests mit RestAssured
und AssertJ
geschrieben.
6.1. LoginTest
Dieser Test prüft ausschließlich die Login-Funktionalität – mit gültigen, ungültigen und fehlenden Zugangsdaten.
@QuarkusTest
public class LoginTest {
@Test
public void given_validCredentials_when_login_then_returned200() {
// Arrange
Credentials credentials = new Credentials("admin", "password");
//Act
Response response = given()
.contentType(MediaType.APPLICATION_JSON)
.body(credentials)
.when()
.post("/login");
// Perform the GET request
// Assert
assertThat(response.getStatusCode()).isEqualTo(200);
}
@Test
public void given_invalidCredentials_when_login_then_returned401() {
// Arrange
Credentials credentials = new Credentials("admin", "wrong");
//Act
Response response = given()
.contentType(MediaType.APPLICATION_JSON)
.body(credentials)
.when()
.post("/login");
// Perform the GET request
// Assert
assertThat(response.getStatusCode()).isEqualTo(401);
}
@Test
public void given_noCredentials_when_login_then_returned401() {
//Act
Response response = given()
.contentType(MediaType.APPLICATION_JSON)
.when()
.post("/login");
// Perform the GET request
// Assert
assertThat(response.getStatusCode()).isEqualTo(401);
}
}
6.2. AuthTest
Hier wird zusätzlich geprüft, ob ein Benutzer mit gültiger Session auf eine geschützte Ressource zugreifen darf – und ob falsche oder fehlende Sessions korrekt behandelt werden.
@QuarkusTest
public class AuthTest {
@Test
public void given_correctCredentials_when_exampleResourceCalled_then_200() {
// Arrange
Credentials credentials = new Credentials("admin", "password");
Response response = given()
.contentType(MediaType.APPLICATION_JSON)
.body(credentials)
.when()
.post("/login");
String cookie = response.headers().getValue("Set-Cookie");
//Act
Response responseAuth = RestAssured
.given()
.header("Cookie", cookie)
.when()
.get("/hello"); // Perform the GET request
// Assert
assertThat(responseAuth.getStatusCode()).isEqualTo(200);
}
@Test
public void given_wrongPassword_when_exampleResourceCalled_then_403() {
// Arrange
Credentials credentials = new Credentials("admin2", "password2");
Response response = given()
.contentType(MediaType.APPLICATION_JSON)
.body(credentials)
.when()
.post("/login");
String cookie = response.headers().getValue("Set-Cookie");
// Act
Response responseAuth = RestAssured
.given()
.header("Cookie", cookie)
.when()
.get("/hello"); // Perform the GET request
// Assert
assertThat(responseAuth.getStatusCode()).isEqualTo(403);
}
@Test
public void given_noPassword_when_exampleResourceCalled_then_401() {
// Act
Response response = RestAssured
.when()
.get("/hello"); // Perform the GET request
// Assert
assertThat(response.getStatusCode()).isEqualTo(401);
}
}
7. Initialdaten und Rollen
7.1. InitBean
Beim Starten der Anwendung werden automatisch zwei Benutzer erstellt und in die Datenbank geschrieben. Diese können direkt zum Testen der Login-Funktion verwendet werden.
@ApplicationScoped
public class InitBean {
@Inject
EntityManager em;
@Transactional
void startUp(@Observes StartupEvent event){
Log.info("it is working");
User user = new User("admin", "password");
User user2 = new User("admin2", "password2");
em.persist(user);
em.persist(user2);
em.flush();
}
}
7.2. Rollen
Die Klasse Roles
definiert die verfügbaren Benutzerrollen als Konstanten. Diese könnten später zur rollenbasierten Zugriffskontrolle verwendet werden.
public class Roles {
public static final String ADMIN = "admin";
public static final String USER = "user";
}