Add OIDC sign-in (#155)

Signed-off-by: elyviere <ir5d1xr6@anonaddy.me>
Co-authored-by: elyviere <ir5d1xr6@anonaddy.me>
This commit is contained in:
Elyviere
2025-08-23 11:43:01 +02:00
committed by GitHub
parent 21376b3ffa
commit 4a66b050e4
13 changed files with 228 additions and 38 deletions

BIN
.github/screenshots/login.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

3
.gitignore vendored
View File

@@ -23,7 +23,8 @@
hs_err_pid*
replay_pid*
.aider*
.aider*
/target/
/.idea/
*secrets.properties

View File

@@ -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://<your-reitti-url>/login/oauth2/code/oauth (e.g. `https://reitti.internal/login/oauth2/code/oauth`)
- (Optional) Logout callback URL: https://<your-reitti-url>/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

View File

@@ -82,6 +82,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>

View File

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

View File

@@ -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<HttpSecurity> oauth2LoginConfigurer(
CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler,
CustomOidcUserService customOidcUserService) {
return new OAuth2LoginConfigurer<HttpSecurity>()
.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;
}
}

View File

@@ -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();
}

View File

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

View File

@@ -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<String, Object> attributes = null;
private Map<String, Object> 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<String, Object> getAttributes() {
return attributes;
}
@Override
public Map<String, Object> getClaims() {
return claims;
}
@Override
public OidcUserInfo getUserInfo() {
return userInfo;
}
@Override
public OidcIdToken getIdToken() {
return token;
}
}

View File

@@ -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}

View File

@@ -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?

View File

@@ -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=<your_client_secret>
# See example below:
# spring.security.oauth2.client.registration.oauth.client-secret=qY6vInJzvtR9jK1rylp9nL1hsMPi2UGi

View File

@@ -136,6 +136,13 @@
<button type="submit" th:text="#{login.button}">Login</button>
</form>
<div th:if="${oidcEnabled}">
<hr style="margin: 30px 0; border: none; border-top: 1px solid var(--color-highlight);">
<a href="/oauth2/authorization/oauth" style="text-decoration: none;">
<button type="button">Log in with OAuth</button>
</a>
</div>
</div>
</body>
</html>