diff --git a/.github/screenshots/login.png b/.github/screenshots/login.png new file mode 100644 index 00000000..4fe932a5 Binary files /dev/null and b/.github/screenshots/login.png differ diff --git a/.gitignore b/.gitignore index e9ee51d3..fc02ffa9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,8 @@ hs_err_pid* replay_pid* -.aider* .aider* /target/ /.idea/ + +*secrets.properties diff --git a/README.md b/README.md index 2ab35fdc..9e3ee200 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - ![](.github/banner.png) @@ -18,6 +17,10 @@ Reitti is a comprehensive personal location tracking and analysis application th ![](.github/screenshots/statistics.png) +### Login page + +![](.github/screenshots/login.png) + ### Core Location Analysis - **Visit Detection**: Automatically identify places where you spend time - **Trip Analysis**: Track your movements between locations with transport mode detection (walking, cycling, driving) @@ -180,30 +183,35 @@ The included `docker-compose.yml` provides a complete setup with: ### Environment Variables -| Variable | Description | Default | -|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------| -| `POSTGIS_HOST` | PostgreSQL database host | postgis | -| `POSTGIS_PORT` | PostgreSQL database port | 5432 | -| `POSTGIS_DB` | PostgreSQL database name | reittidb | -| `POSTGIS_USER` | Database username | reitti | -| `POSTGIS_PASSWORD` | Database password | reitti | -| `RABBITMQ_HOST` | RabbitMQ host | rabbitmq | -| `RABBITMQ_PORT` | RabbitMQ port | 5672 | -| `RABBITMQ_USER` | RabbitMQ username | reitti | -| `RABBITMQ_PASSWORD` | RabbitMQ password | reitti | -| `REDIS_HOST` | Redis host | redis | -| `REDIS_PORT` | Redis port | 6379 | -| `REDIS_USERNAME` | Redis username (optional) | | -| `REDIS_PASSWORD` | Redis password (optional) | | -| `PHOTON_BASE_URL` | Base URL for Photon geocoding service | | -| `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | -| `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | -| `CUSTOM_TILES_SERVICE` | Custom tile service URL template (e.g., `https://tiles.example.com/{z}/{x}/{y}.png`) | | -| `CUSTOM_TILES_ATTRIBUTION` | Custom attribution text for the tile service | | -| `SERVER_PORT` | Application server port | 8080 | -| `APP_UID` | User ID to run the application as | 1000 | -| `APP_GID` | Group ID to run the application as | 1000 | -| `JAVA_OPTS` | JVM options | | +| Variable | Description | Default | Example | +|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------------------| +| `POSTGIS_HOST` | PostgreSQL database host | postgis | postgis | +| `POSTGIS_PORT` | PostgreSQL database port | 5432 | 5432 | +| `POSTGIS_DB` | PostgreSQL database name | reittidb | reittidb | +| `POSTGIS_USER` | Database username | reitti | reitti | +| `POSTGIS_PASSWORD` | Database password | reitti | reitti | +| `RABBITMQ_HOST` | RabbitMQ host | rabbitmq | rabbitmq | +| `RABBITMQ_PORT` | RabbitMQ port | 5672 | 5672 | +| `RABBITMQ_USER` | RabbitMQ username | reitti | reitti | +| `RABBITMQ_PASSWORD` | RabbitMQ password | reitti | reitti | +| `REDIS_HOST` | Redis host | redis | redis | +| `REDIS_PORT` | Redis port | 6379 | 6379 | +| `REDIS_USERNAME` | Redis username (optional) | | username | +| `REDIS_PASSWORD` | Redis password (optional) | | password | +| `OIDC_ENABLED` | Whether to enable OIDC sign-ins | false | true | +| `OIDC_CLIENT_ID` | Your OpenID Connect Client ID (from your provider) | | google | +| `OIDC_CLIENT_SECRET` | Your OpenID Connect Client secret (from your provider) | | F0oxfg8b2rp5X97YPS92C2ERxof1oike | +| `OIDC_PROVIDER_URI` | Your OpenID Connect Provider Discovery URI (don't include the /.well-known/openid-configuration part of the URI) | | https://github.com/login/oauth | +| `OIDC_SCOPE` | Your OpenID Connect scopes for your user (set to the values in the example if you're unsure) | | openid,profile | +| `PHOTON_BASE_URL` | Base URL for Photon geocoding service | | | +| `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | 15 | +| `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | true | +| `CUSTOM_TILES_SERVICE` | Custom tile service URL template | | https://tiles.example.com/{z}/{x}/{y}.png | +| `CUSTOM_TILES_ATTRIBUTION` | Custom attribution text for the tile service | | | +| `SERVER_PORT` | Application server port | 8080 | 8080 | +| `APP_UID` | User ID to run the application as | 1000 | 1000 | +| `APP_GID` | Group ID to run the application as | 1000 | 1000 | +| `JAVA_OPTS` | JVM options | | | ### Tags @@ -351,6 +359,31 @@ Use both Photon and external services for maximum reliability. - Check rate limits and usage policies - Consider geographic coverage of different providers +## Open ID Connect (OIDC) +Reitti supports using a third party OIDC provider for sign-ins. It provides the following environment variables which are required for OIDC authentication. + +- `OIDC_ENABLED` +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` +- `OIDC_ISSUER_URI` +- `OIDC_SCOPE` (should usually be set to "openid,profile") + +Setting `OIDC_ENABLED = true` enables OIDC, whereas the remaining need to be found from your OIDC provider, e.g. github. See the [Environment Variables](#environment-variables) section for examples. + +There are two URLs provided by reitti that you should give to your OIDC provider (see their documentation for further information on this), one of which is required. +- (Required) Callback URL: https:///login/oauth2/code/oauth (e.g. `https://reitti.internal/login/oauth2/code/oauth`) +- (Optional) Logout callback URL: https:///logout/connect/back-channel/oauth + +The logout callback URL will allow your OIDC provider to sign you out of Reitti when you sign out from your provider. If you don't set it, you will have to manually sign out of Reitti even if you sign out from your OIDC provider. + +### Login Requirements +We don't support sign-ups via OIDC, so in order to sign in you will first have to create a user account in Reitti. +This user **must** have a matching username to the username provided by your OIDC provider, commonly referred to as the `preferred_username`. + +E.g. If you have the username "myusername" with your OIDC provider, create a user in Reitti with the username "myusername". The "Display Name" can be set freely. + +**Note for Google accounts**: Google accounts use the email address as the `preferred_username`, so if you're using gmail you'd create an account with the username "myemail@gmail.com". + ## Technologies - **Backend**: Spring Boot, Spring Data JPA, Spring Security diff --git a/pom.xml b/pom.xml index 9da04763..029ca76c 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-client + org.thymeleaf.extras thymeleaf-extras-springsecurity6 diff --git a/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java b/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java new file mode 100644 index 00000000..30601be4 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/config/CustomOidcUserService.java @@ -0,0 +1,35 @@ +package com.dedicatedcode.reitti.config; + +import com.dedicatedcode.reitti.model.User; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Component; + +import com.dedicatedcode.reitti.repository.UserJdbcService; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class CustomOidcUserService extends OidcUserService { + + private final UserJdbcService userJdbcService; + + public CustomOidcUserService(UserJdbcService userJdbcService) { + this.userJdbcService = userJdbcService; + } + + @Override + @Transactional + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + var preferredUsername = userRequest.getIdToken().getPreferredUsername(); + + User user = userJdbcService.findByUsername(preferredUsername) + .orElseThrow(() -> new UsernameNotFoundException("No internal user found for username: " + preferredUsername)); + + user.setOidcUser(super.loadUser(userRequest)); + return user; + } +} + diff --git a/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java b/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java new file mode 100644 index 00000000..c5666acb --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/config/OidcSecurityConfiguration.java @@ -0,0 +1,35 @@ +package com.dedicatedcode.reitti.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; +import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; + +@Configuration +@ConditionalOnProperty(name = "reitti.security.oidc.enabled", havingValue = "true") +public class OidcSecurityConfiguration { + + @Bean + public OAuth2LoginConfigurer oauth2LoginConfigurer( + CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler, + CustomOidcUserService customOidcUserService) { + return new OAuth2LoginConfigurer() + .loginPage("/login") + .userInfoEndpoint(userInfo -> userInfo.oidcUserService(customOidcUserService)) + .successHandler(customAuthenticationSuccessHandler); + } + + @Bean + public LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) { + OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler( + clientRegistrationRepository); + + oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout"); + + return oidcLogoutSuccessHandler; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java index a92d9362..7f588b0e 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java +++ b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java @@ -3,14 +3,13 @@ package com.dedicatedcode.reitti.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.AuthorizationFilter; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; @Configuration @EnableWebSecurity @@ -25,10 +24,14 @@ public class SecurityConfig { @Autowired private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; + @Autowired(required = false) + private LogoutSuccessHandler oidcLogoutSuccessHandler; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/login").permitAll() .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() @@ -39,7 +42,6 @@ public class SecurityConfig { .formLogin(form -> form .loginPage("/login") .successHandler(customAuthenticationSuccessHandler) - .permitAll() ) .rememberMe(rememberMe -> rememberMe .key("uniqueAndSecretKey") @@ -47,11 +49,23 @@ public class SecurityConfig { .rememberMeParameter("remember-me") .useSecureCookie(false) ) - .logout(logout -> logout - .logoutSuccessUrl("/login?logout") - .deleteCookies("JSESSIONID", "remember-me") - .permitAll() - ); + .logout(logout -> { + if (oidcLogoutSuccessHandler != null) { + logout.logoutSuccessHandler(oidcLogoutSuccessHandler); + } + logout.deleteCookies("JSESSIONID", "remember-me") + .permitAll(); + }); + + // Apply OAuth2 configuration if OIDC is enabled + if (oidcLogoutSuccessHandler != null) { + http.oauth2Login(oauth2 -> oauth2 + .loginPage("/login") + .successHandler(customAuthenticationSuccessHandler) + ) + .oauth2Client(Customizer.withDefaults()) + .oidcLogout((logout) -> logout.backChannel(Customizer.withDefaults())); + } return http.build(); } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/WebViewController.java b/src/main/java/com/dedicatedcode/reitti/controller/WebViewController.java index fe4f439f..2e259c52 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/WebViewController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/WebViewController.java @@ -9,9 +9,12 @@ import org.springframework.web.bind.annotation.GetMapping; @Controller public class WebViewController { private final boolean dataManagementEnabled; + private final boolean oidcEnabled; - public WebViewController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) { + public WebViewController(@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled, + @Value("${reitti.security.oidc.enabled:false}") boolean oidcEnabled) { this.dataManagementEnabled = dataManagementEnabled; + this.oidcEnabled = oidcEnabled; } @GetMapping("/") @@ -25,7 +28,8 @@ public class WebViewController { } @GetMapping("/login") - public String login() { + public String login(Model model) { + model.addAttribute("oidcEnabled", oidcEnabled); return "login"; } diff --git a/src/main/java/com/dedicatedcode/reitti/model/User.java b/src/main/java/com/dedicatedcode/reitti/model/User.java index 3d75d29d..04ed59d2 100644 --- a/src/main/java/com/dedicatedcode/reitti/model/User.java +++ b/src/main/java/com/dedicatedcode/reitti/model/User.java @@ -3,11 +3,15 @@ package com.dedicatedcode.reitti.model; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import java.util.Collection; import java.util.List; +import java.util.Map; -public class User implements UserDetails { +public class User implements UserDetails, OidcUser { private final Long id; private final String username; @@ -15,6 +19,10 @@ public class User implements UserDetails { private final String displayName; private final Role role; private final Long version; + private OidcIdToken token = null; + private OidcUserInfo userInfo = null; + private Map attributes = null; + private Map claims = null; public User() { this(null, null, null, null, Role.USER, null); @@ -101,4 +109,36 @@ public class User implements UserDetails { public User withRole(Role role) { return new User(this.id, this.username, this.password, this.displayName, role, this.version); } + + public void setOidcUser(OidcUser oidcUser) { + token = oidcUser.getIdToken(); + userInfo = oidcUser.getUserInfo(); + attributes = oidcUser.getAttributes(); + claims = oidcUser.getClaims(); + } + + @Override + public String getName() { + return username; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Map getClaims() { + return claims; + } + + @Override + public OidcUserInfo getUserInfo() { + return userInfo; + } + + @Override + public OidcIdToken getIdToken() { + return token; + } } diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index 25f6f03f..3ffab8fa 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -16,6 +16,12 @@ spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.username=${REDIS_USERNAME:} spring.data.redis.password=${REDIS_PASSWORD:} +reitti.security.oidc.enabled=${OIDC_ENABLED:false} +spring.security.oauth2.client.registration.oauth.client-id=${OIDC_CLIENT_ID:} +spring.security.oauth2.client.registration.oauth.client-secret=${OIDC_CLIENT_SECRET:} +spring.security.oauth2.client.provider.oauth.issuer-uri=${OIDC_ISSUER_URI:} +spring.security.oauth2.client.registration.oauth.scope=${OIDC_SCOPE:} + reitti.data-management.enabled=${DANGEROUS_LIFE:false} reitti.import.processing-idle-start-time=${PROCESSING_WAIT_TIME:15} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 91abce0b..baaca10b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,5 @@ +# spring.config.import=optional:oidc.properties + # Server configuration server.port=8080 @@ -43,6 +45,10 @@ spring.servlet.multipart.max-file-size=5GB spring.servlet.multipart.max-request-size=5GB server.tomcat.max-part-count=100 +# OAuth configuration +# For now, we only support having one OIDC provider. If you need multiple, create a ticket in the reitti github. +reitti.security.oidc.enabled=false + # Application specific settings reitti.import.batch-size=1000 # How many seconds should we wait after the last data input before starting to process all unprocessed data? diff --git a/src/main/resources/secrets.properties.example b/src/main/resources/secrets.properties.example new file mode 100644 index 00000000..8fa6fb20 --- /dev/null +++ b/src/main/resources/secrets.properties.example @@ -0,0 +1,5 @@ +# This file should be copied and renamed to "secrets.properties". WARNING: Do not make changes in the .example-file, you may accidentally expose your secret. + +spring.security.oauth2.client.registration.oauth.client-secret= +# See example below: +# spring.security.oauth2.client.registration.oauth.client-secret=qY6vInJzvtR9jK1rylp9nL1hsMPi2UGi diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index ba85c8bf..c0e81888 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -136,6 +136,13 @@ +