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);

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";
}