From 25117c8187ebc83f93006d13c3427a7e687e632e Mon Sep 17 00:00:00 2001
From: Andreas Svanberg <andreass@dsv.su.se>
Date: Mon, 16 Dec 2024 16:55:49 +0100
Subject: [PATCH] Switch authentication to OAuth 2 (#27)

This is one requirement in bringing #15 to reality.

Currently the way to log in to SciPro is by having a locally modified `web.xml` that emulates being authenticated via single sign-on (SSO). This method can not work on an automatically deployed test server. It is also not possible to have real SSO configured for the test servers due to their dynamic nature and that they are given a new hostname each time. Our current SSO solution requires there to be certificate issued to specific hostnames. Even if it were possible to get SSO set up how would the username received from SSO match to test data? We would have to have real usernames in our test data which is not desirable.

To solve both of the problems described above - requiring a locally modified version of a git tracked file and needing an authentication mechanism that works for dynamic test servers - a change of the authentication mechanism from Tomcat controlled SSO to application controlled OAuth 2 is proposed. There is already an OAuth 2 authorization server running in production which itself is authenticates users via SSO that will be used in production and for the permanent test servers. In development and for the dynamic test servers a local authorization server running in Docker is provided.

For "regular" users there will be no noticeable change, they will be prompted to log in via SSO and then they get access to the system. For users with high developer access they will, on the permanent test servers, be prompted to "issue token". On that page they can use the top form to authenticate as themselves based on their SSO authentication, or use the bottom form to issue a completely custom authentication and log in as whatever username they deem necessary. The temporary test servers and during local development will work similarly with the only difference being that there is no SSO log in first and you will be prompted to issue a token immediately. The default authentication (top form) will be a local sys-admin level user.

## How to test
1. Start the local OAuth 2 authorization server with `docker compose up`
2. Start SciPro
3. Attempt to log in

Co-authored-by: Nico Athanassiadis <nico@dsv.su.se>
Reviewed-on: https://gitea.dsv.su.se/DMC/scipro/pulls/27
Reviewed-by: Nico Athanassiadis <nico@dsv.su.se>
---
 README.md                                     |  9 ++
 .../se/su/dsv/scipro/DataInitializer.java     |  4 +
 .../scipro/system/AuthenticationContext.java  | 15 +++
 .../impl/ImporterTransactionsImpl.java        |  1 +
 docker-compose.yml                            | 11 +++
 .../scipro/CurrentUserFromWicketSession.java  | 14 ---
 .../dsv/scipro/loginlogout/pages/SSOPage.java |  7 +-
 .../security/auth/MockRemoteUserFilter.java   | 81 ---------------
 .../su/dsv/scipro/session/SciProSession.java  | 13 +--
 .../java/se/su/dsv/scipro/SciProTest.java     |  6 +-
 war/pom.xml                                   |  4 +
 .../war/CurrentUserFromSpringSecurity.java    | 98 +++++++++++++++++++
 .../main/java/se/su/dsv/scipro/war/Main.java  |  6 --
 .../dsv/scipro/war/WicketConfiguration.java   | 27 +++++
 war/src/main/resources/application.properties | 11 +++
 15 files changed, 192 insertions(+), 115 deletions(-)
 create mode 100644 core/src/main/java/se/su/dsv/scipro/system/AuthenticationContext.java
 delete mode 100644 view/src/main/java/se/su/dsv/scipro/CurrentUserFromWicketSession.java
 delete mode 100755 view/src/main/java/se/su/dsv/scipro/security/auth/MockRemoteUserFilter.java
 create mode 100644 war/src/main/java/se/su/dsv/scipro/war/CurrentUserFromSpringSecurity.java

diff --git a/README.md b/README.md
index f5b81e7a88..22533e79fc 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,12 @@
+## Working with the web GUI (Wicket)
+The web GUI is protected by OAuth 2 log in. Run the Docker Compose containers with
+`docker compose up` to start the authorization server to be able to log in.
+
+If you run SciPro in development mode (DEV profile) you will be able to log in
+as the "default" OAuth 2 user populated in the upper form. If you have other
+data in your database you will have to use the lower form and specify a valid
+username in the principal field.
+
 ## Working with the API
 The API is protected by OAuth 2 acting as a [resource server](https://www.oauth.com/oauth2-servers/the-resource-server/)
 verifying tokens using [token introspection](https://datatracker.ietf.org/doc/html/rfc7662).
diff --git a/core/src/main/java/se/su/dsv/scipro/DataInitializer.java b/core/src/main/java/se/su/dsv/scipro/DataInitializer.java
index aee6d0b488..7ab805cd7a 100644
--- a/core/src/main/java/se/su/dsv/scipro/DataInitializer.java
+++ b/core/src/main/java/se/su/dsv/scipro/DataInitializer.java
@@ -206,6 +206,10 @@ public class DataInitializer implements Lifecycle {
         admin.addRole(Roles.SYSADMIN);
         createBeta(admin);
         passwordService.updatePassword(admin, "aey7ru8aefei0jaW2wo9eX8EiShi0aan");
+        Username defaultOAuth2Principal = new Username();
+        defaultOAuth2Principal.setUsername("dev@localhost");
+        defaultOAuth2Principal.setUser(admin);
+        save(defaultOAuth2Principal);
     }
 
     private void createBeta(User user) {
diff --git a/core/src/main/java/se/su/dsv/scipro/system/AuthenticationContext.java b/core/src/main/java/se/su/dsv/scipro/system/AuthenticationContext.java
new file mode 100644
index 0000000000..9f46604358
--- /dev/null
+++ b/core/src/main/java/se/su/dsv/scipro/system/AuthenticationContext.java
@@ -0,0 +1,15 @@
+package se.su.dsv.scipro.system;
+
+/**
+ * Information about the current authentication context.
+ * <p>
+ * The difference between this and {@link CurrentUser} is that a user can be
+ * authenticated without being a user in the system. This can happen when a
+ * user logs in for the first time via SSO. The {@link #set(User)} method can
+ * be used if the user can be imported based on the {@link #getPrincipalName()}.
+ */
+public interface AuthenticationContext extends CurrentUser {
+    void set(User user);
+
+    String getPrincipalName();
+}
diff --git a/daisy-integration/src/main/java/se/su/dsv/scipro/daisyExternal/impl/ImporterTransactionsImpl.java b/daisy-integration/src/main/java/se/su/dsv/scipro/daisyExternal/impl/ImporterTransactionsImpl.java
index 0eca54eadf..395f5485ef 100644
--- a/daisy-integration/src/main/java/se/su/dsv/scipro/daisyExternal/impl/ImporterTransactionsImpl.java
+++ b/daisy-integration/src/main/java/se/su/dsv/scipro/daisyExternal/impl/ImporterTransactionsImpl.java
@@ -171,6 +171,7 @@ public class ImporterTransactionsImpl implements ImporterTransactions {
                 username.setUsername(completeUsername);
                 username.setUser(local);
                 userNameService.save(username);
+                local.getUsernames().add(username);
             }
         }
     }
diff --git a/docker-compose.yml b/docker-compose.yml
index 637455a39e..aac221e274 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,3 +13,14 @@ services:
       - CLIENT_REDIRECT_URI=http://localhost:59732/
       - RESOURCE_SERVER_ID=scipro-api-client
       - RESOURCE_SERVER_SECRET=scipro-api-secret
+  oauth2-wicket:
+    build:
+      context: https://github.com/dsv-su/toker.git
+      dockerfile: embedded.Dockerfile
+    restart: on-failure
+    ports:
+      - '59734:8080'
+    environment:
+      - CLIENT_ID=scipro
+      - CLIENT_SECRET=s3cr3t
+      - CLIENT_REDIRECT_URI=http://localhost:8080/login/oauth2/code/scipro
diff --git a/view/src/main/java/se/su/dsv/scipro/CurrentUserFromWicketSession.java b/view/src/main/java/se/su/dsv/scipro/CurrentUserFromWicketSession.java
deleted file mode 100644
index a13b465884..0000000000
--- a/view/src/main/java/se/su/dsv/scipro/CurrentUserFromWicketSession.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package se.su.dsv.scipro;
-
-import org.apache.wicket.Session;
-import se.su.dsv.scipro.session.SciProSession;
-import se.su.dsv.scipro.system.CurrentUser;
-import se.su.dsv.scipro.system.User;
-
-public class CurrentUserFromWicketSession implements CurrentUser {
-
-    @Override
-    public User get() {
-        return Session.exists() ? SciProSession.get().getUser() : null;
-    }
-}
diff --git a/view/src/main/java/se/su/dsv/scipro/loginlogout/pages/SSOPage.java b/view/src/main/java/se/su/dsv/scipro/loginlogout/pages/SSOPage.java
index 8df12a7cce..cb9a802c52 100644
--- a/view/src/main/java/se/su/dsv/scipro/loginlogout/pages/SSOPage.java
+++ b/view/src/main/java/se/su/dsv/scipro/loginlogout/pages/SSOPage.java
@@ -1,12 +1,12 @@
 package se.su.dsv.scipro.loginlogout.pages;
 
 import jakarta.inject.Inject;
-import jakarta.servlet.http.HttpServletRequest;
 import java.util.Optional;
 import java.util.Set;
 import se.su.dsv.scipro.basepages.PublicPage;
 import se.su.dsv.scipro.security.auth.Authorization;
 import se.su.dsv.scipro.session.SciProSession;
+import se.su.dsv.scipro.system.AuthenticationContext;
 import se.su.dsv.scipro.system.User;
 import se.su.dsv.scipro.system.UserImportService;
 import se.su.dsv.scipro.system.UserService;
@@ -20,8 +20,11 @@ public class SSOPage extends PublicPage {
     @Inject
     private UserService userService;
 
+    @Inject
+    private AuthenticationContext authenticationContext;
+
     public SSOPage() {
-        String remoteUserName = ((HttpServletRequest) getRequest().getContainerRequest()).getRemoteUser();
+        String remoteUserName = authenticationContext.getPrincipalName();
         User user = userService.findByUsername(remoteUserName);
 
         if (user != null) {
diff --git a/view/src/main/java/se/su/dsv/scipro/security/auth/MockRemoteUserFilter.java b/view/src/main/java/se/su/dsv/scipro/security/auth/MockRemoteUserFilter.java
deleted file mode 100755
index c8ab375393..0000000000
--- a/view/src/main/java/se/su/dsv/scipro/security/auth/MockRemoteUserFilter.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package se.su.dsv.scipro.security.auth;
-
-import jakarta.servlet.*;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletRequestWrapper;
-import java.io.IOException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Throw-away implementation of a servlet filter, main task is to fake the getRemoteUser() call for the request chain.
- */
-public final class MockRemoteUserFilter implements Filter {
-
-    private static final Logger LOGGER = LoggerFactory.getLogger(MockRemoteUserFilter.class);
-    //Default value unless supplied via init parameter
-    private String fakedUser = "SOME_GUY";
-    private FilterConfig cfg = null;
-
-    /**
-     * Default constructor.
-     */
-    public MockRemoteUserFilter() {}
-
-    /**
-     * @see Filter#destroy()
-     */
-    @Override
-    public void destroy() {
-        cfg = null;
-    }
-
-    /**
-     * Wraps the passed request and alters the behavior of getRemoteUser() for later links of the chain.
-     * @see Filter#doFilter(ServletRequest, ServletResponse, FilterChain)
-     */
-    @Override
-    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-        throws IOException, ServletException {
-        LOGGER.debug("Faking external authentication user: " + fakedUser);
-        if (cfg != null) {
-            HttpServletRequestWrapper wrapper = new ModifiedRemoteUserRequestWrapper(
-                (HttpServletRequest) request,
-                fakedUser
-            );
-            // pass the request along the filter chain
-            chain.doFilter(wrapper, response);
-            return;
-        }
-        chain.doFilter(request, response);
-    }
-
-    /**
-     * @see Filter#init(FilterConfig)
-     */
-    @Override
-    public void init(FilterConfig fConfig) {
-        cfg = fConfig;
-        if (cfg != null) {
-            fakedUser = cfg.getInitParameter("fakedUser");
-        }
-    }
-
-    /**
-     * Private RequestWrapper, of no interest to anyone outside of this class.
-     */
-    static class ModifiedRemoteUserRequestWrapper extends HttpServletRequestWrapper {
-
-        private final String fakedUser;
-
-        ModifiedRemoteUserRequestWrapper(final HttpServletRequest request, final String fakedUser) {
-            super(request);
-            this.fakedUser = fakedUser;
-        }
-
-        @Override
-        public String getRemoteUser() {
-            return fakedUser;
-        }
-    }
-}
diff --git a/view/src/main/java/se/su/dsv/scipro/session/SciProSession.java b/view/src/main/java/se/su/dsv/scipro/session/SciProSession.java
index 75af2b3bff..202e061242 100755
--- a/view/src/main/java/se/su/dsv/scipro/session/SciProSession.java
+++ b/view/src/main/java/se/su/dsv/scipro/session/SciProSession.java
@@ -5,24 +5,21 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Set;
-import org.apache.wicket.MetaDataKey;
 import org.apache.wicket.Session;
 import org.apache.wicket.injection.Injector;
 import org.apache.wicket.protocol.http.WebSession;
 import org.apache.wicket.request.Request;
 import se.su.dsv.scipro.security.auth.roles.IRole;
 import se.su.dsv.scipro.security.auth.roles.Roles;
+import se.su.dsv.scipro.system.AuthenticationContext;
 import se.su.dsv.scipro.system.ProjectModule;
 import se.su.dsv.scipro.system.SystemModule;
 import se.su.dsv.scipro.system.User;
-import se.su.dsv.scipro.system.UserService;
 
 public class SciProSession extends WebSession {
 
-    private static final MetaDataKey<Long> LOGGED_IN_USER_ID = new MetaDataKey<>() {};
-
     @Inject
-    private UserService userService;
+    private AuthenticationContext authenticationContext;
 
     private Set<ProjectModule> projectModules = new HashSet<>();
     private Set<SystemModule> systemModules = new HashSet<>();
@@ -37,15 +34,15 @@ public class SciProSession extends WebSession {
     }
 
     public synchronized void setUser(User user) {
-        setMetaData(LOGGED_IN_USER_ID, user.getId());
+        authenticationContext.set(user);
     }
 
     public synchronized User getUser() {
-        return isLoggedIn() ? userService.findOne(getMetaData(LOGGED_IN_USER_ID)) : null;
+        return authenticationContext.get();
     }
 
     public synchronized boolean isLoggedIn() {
-        return getMetaData(LOGGED_IN_USER_ID) != null;
+        return authenticationContext.get() != null;
     }
 
     public synchronized boolean authorizedForRole(Roles role) {
diff --git a/view/src/test/java/se/su/dsv/scipro/SciProTest.java b/view/src/test/java/se/su/dsv/scipro/SciProTest.java
index ceec7ebadb..768b84e0df 100755
--- a/view/src/test/java/se/su/dsv/scipro/SciProTest.java
+++ b/view/src/test/java/se/su/dsv/scipro/SciProTest.java
@@ -3,7 +3,6 @@ package se.su.dsv.scipro;
 import static org.mockito.ArgumentMatchers.*;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
 
 import com.google.common.eventbus.EventBus;
 import java.lang.reflect.Field;
@@ -119,12 +118,11 @@ import se.su.dsv.scipro.springdata.services.UnitService;
 import se.su.dsv.scipro.springdata.services.UserProfileService;
 import se.su.dsv.scipro.supervisor.pages.SupervisorStartPage;
 import se.su.dsv.scipro.survey.SurveyService;
-import se.su.dsv.scipro.system.CurrentUser;
+import se.su.dsv.scipro.system.AuthenticationContext;
 import se.su.dsv.scipro.system.ExternalResourceService;
 import se.su.dsv.scipro.system.FooterAddressRepo;
 import se.su.dsv.scipro.system.FooterLinkService;
 import se.su.dsv.scipro.system.GenericService;
-import se.su.dsv.scipro.system.Lifecycle;
 import se.su.dsv.scipro.system.PasswordRepo;
 import se.su.dsv.scipro.system.PasswordService;
 import se.su.dsv.scipro.system.ProjectModule;
@@ -369,7 +367,7 @@ public abstract class SciProTest {
     protected ChecklistAnswerService checklistAnswerService;
 
     @Mock
-    protected CurrentUser currentUser;
+    protected AuthenticationContext authenticationContext;
 
     @Mock
     private Scheduler scheduler;
diff --git a/war/pom.xml b/war/pom.xml
index 2c4ad17242..456eeae393 100644
--- a/war/pom.xml
+++ b/war/pom.xml
@@ -41,6 +41,10 @@
             <artifactId>spring-boot-starter-tomcat</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-oauth2-client</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.springframework</groupId>
             <artifactId>spring-orm</artifactId>
diff --git a/war/src/main/java/se/su/dsv/scipro/war/CurrentUserFromSpringSecurity.java b/war/src/main/java/se/su/dsv/scipro/war/CurrentUserFromSpringSecurity.java
new file mode 100644
index 0000000000..6f209f38aa
--- /dev/null
+++ b/war/src/main/java/se/su/dsv/scipro/war/CurrentUserFromSpringSecurity.java
@@ -0,0 +1,98 @@
+package se.su.dsv.scipro.war;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Provider;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.security.Principal;
+import java.util.Collections;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.context.SecurityContextRepository;
+import se.su.dsv.scipro.system.AuthenticationContext;
+import se.su.dsv.scipro.system.User;
+import se.su.dsv.scipro.system.UserService;
+import se.su.dsv.scipro.system.Username;
+
+public class CurrentUserFromSpringSecurity implements AuthenticationContext {
+
+    private final UserService userService;
+
+    // injecting providers since this is a singleton and the request and response are not
+    private final Provider<HttpServletRequest> currentRequest;
+    private final Provider<HttpServletResponse> currentResponse;
+
+    // hardcoded since that is what Spring Security does (see SwitchUserFilter)
+    private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
+
+    @Inject
+    public CurrentUserFromSpringSecurity(
+        UserService userService,
+        Provider<HttpServletRequest> currentRequest,
+        Provider<HttpServletResponse> currentResponse
+    ) {
+        this.userService = userService;
+        this.currentRequest = currentRequest;
+        this.currentResponse = currentResponse;
+    }
+
+    @Override
+    public User get() {
+        SecurityContext context = SecurityContextHolder.getContext();
+        Authentication authentication = context.getAuthentication();
+        if (authentication == null) {
+            return null;
+        }
+        String username = authentication.getName();
+        return userService.findByUsername(username);
+    }
+
+    // Implementing switch user manually rather than using the built-in Spring Security switch user feature
+    // due to compatibility with Wicket.
+    // Wicket does not supply a form with a username field since it has some JavaScript based auto-complete
+    // person finder.
+    // See Spring's SwitchUserFilter for the built-in switch user feature from where most of the code is copied.
+    @Override
+    public void set(User user) {
+        SecurityContextHolderStrategy strategy = SecurityContextHolder.getContextHolderStrategy();
+        SecurityContext context = strategy.createEmptyContext();
+        WicketControlledPrincipal principal = new WicketControlledPrincipal(user);
+        UsernamePasswordAuthenticationToken targetUser = UsernamePasswordAuthenticationToken.authenticated(
+            principal,
+            null,
+            Collections.emptyList()
+        );
+        context.setAuthentication(targetUser);
+        strategy.setContext(context);
+        this.securityContextRepository.saveContext(context, currentRequest.get(), currentResponse.get());
+    }
+
+    @Override
+    public String getPrincipalName() {
+        SecurityContext context = SecurityContextHolder.getContext();
+        Authentication authentication = context.getAuthentication();
+        if (authentication == null) {
+            return null;
+        }
+        return authentication.getName();
+    }
+
+    private static final class WicketControlledPrincipal implements Principal {
+
+        private final String username;
+
+        public WicketControlledPrincipal(User user) {
+            // extract any username so that we can look it up later
+            this.username = user.getUsernames().stream().findAny().map(Username::getUsername).orElse("<unknown>");
+        }
+
+        @Override
+        public String getName() {
+            return username;
+        }
+    }
+}
diff --git a/war/src/main/java/se/su/dsv/scipro/war/Main.java b/war/src/main/java/se/su/dsv/scipro/war/Main.java
index 9669f121d6..2a14e64915 100644
--- a/war/src/main/java/se/su/dsv/scipro/war/Main.java
+++ b/war/src/main/java/se/su/dsv/scipro/war/Main.java
@@ -23,7 +23,6 @@ import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.orm.jpa.SharedEntityManagerCreator;
 import org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter;
 import se.su.dsv.scipro.CoreConfig;
-import se.su.dsv.scipro.CurrentUserFromWicketSession;
 import se.su.dsv.scipro.FileSystemStore;
 import se.su.dsv.scipro.RepositoryConfiguration;
 import se.su.dsv.scipro.file.FileStore;
@@ -85,11 +84,6 @@ public class Main extends SpringBootServletInitializer implements ServletContain
         return currentProfile;
     }
 
-    @Bean
-    public CurrentUserFromWicketSession currentUserFromWicketSession() {
-        return new CurrentUserFromWicketSession();
-    }
-
     @Bean
     public FileStore fileStore() {
         return new FileSystemStore();
diff --git a/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java b/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java
index db84241566..90b0b70a61 100644
--- a/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java
+++ b/war/src/main/java/se/su/dsv/scipro/war/WicketConfiguration.java
@@ -1,6 +1,9 @@
 package se.su.dsv.scipro.war;
 
 import com.google.common.eventbus.EventBus;
+import jakarta.inject.Provider;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
 import org.apache.wicket.protocol.http.WebApplication;
 import org.apache.wicket.protocol.http.WicketFilter;
 import org.apache.wicket.spring.injection.annot.SpringComponentInjector;
@@ -8,6 +11,10 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
 import se.su.dsv.scipro.SciProApplication;
 import se.su.dsv.scipro.crosscutting.ForwardPhase2Feedback;
 import se.su.dsv.scipro.crosscutting.NotifyFailedReflection;
@@ -21,6 +28,7 @@ import se.su.dsv.scipro.notifications.NotificationController;
 import se.su.dsv.scipro.profiles.CurrentProfile;
 import se.su.dsv.scipro.reviewing.FinalSeminarApprovalService;
 import se.su.dsv.scipro.reviewing.RoughDraftApprovalService;
+import se.su.dsv.scipro.system.UserService;
 
 @Configuration
 public class WicketConfiguration {
@@ -49,6 +57,25 @@ public class WicketConfiguration {
         return new SciProApplication(currentProfile);
     }
 
+    @Bean
+    @Order(3) // make sure it's after the API security filters
+    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+        http.authorizeHttpRequests(requests -> requests.anyRequest().authenticated());
+        http.oauth2Login(Customizer.withDefaults());
+        http.csrf(csrf -> csrf.disable()); // Wicket has its own CSRF protection
+        http.logout(logout -> logout.logoutUrl("/logout").logoutSuccessUrl("/"));
+        return http.build();
+    }
+
+    @Bean
+    public CurrentUserFromSpringSecurity currentUserFromSpringSecurity(
+        UserService userService,
+        Provider<HttpServletRequest> httpServletRequestProvider,
+        Provider<HttpServletResponse> httpServletResponseProvider
+    ) {
+        return new CurrentUserFromSpringSecurity(userService, httpServletRequestProvider, httpServletResponseProvider);
+    }
+
     @Bean
     public ReviewingNotifications reviewingNotifications(
         EventBus eventBus,
diff --git a/war/src/main/resources/application.properties b/war/src/main/resources/application.properties
index 7754344e7d..f405136272 100644
--- a/war/src/main/resources/application.properties
+++ b/war/src/main/resources/application.properties
@@ -19,3 +19,14 @@ springdoc.swagger-ui.persist-authorization=true
 spring.security.oauth2.resourceserver.opaquetoken.client-id=scipro-api-client
 spring.security.oauth2.resourceserver.opaquetoken.client-secret=scipro-api-secret
 spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=http://localhost:59733/introspect
+
+# Log in via local OAuth 2 authorization server
+spring.security.oauth2.client.provider.docker.user-info-uri=http://localhost:59734/verify
+spring.security.oauth2.client.provider.docker.user-name-attribute=sub
+spring.security.oauth2.client.provider.docker.token-uri=http://localhost:59734/exchange
+spring.security.oauth2.client.provider.docker.authorization-uri=http://localhost:59734/authorize
+spring.security.oauth2.client.registration.scipro.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
+spring.security.oauth2.client.registration.scipro.provider=docker
+spring.security.oauth2.client.registration.scipro.client-id=scipro
+spring.security.oauth2.client.registration.scipro.client-secret=s3cr3t
+spring.security.oauth2.client.registration.scipro.authorization-grant-type=authorization_code