mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 01:17:57 -05:00
Add OIDC sign-in (#155)
Signed-off-by: elyviere <ir5d1xr6@anonaddy.me> Co-authored-by: elyviere <ir5d1xr6@anonaddy.me>
This commit is contained in:
BIN
.github/screenshots/login.png
vendored
Normal file
BIN
.github/screenshots/login.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,7 +23,8 @@
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
.aider*
|
||||
.aider*
|
||||
/target/
|
||||
/.idea/
|
||||
|
||||
*secrets.properties
|
||||
|
||||
83
README.md
83
README.md
@@ -1,4 +1,3 @@
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -18,6 +17,10 @@ Reitti is a comprehensive personal location tracking and analysis application th
|
||||
|
||||

|
||||
|
||||
### Login page
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
4
pom.xml
4
pom.xml
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?
|
||||
|
||||
5
src/main/resources/secrets.properties.example
Normal file
5
src/main/resources/secrets.properties.example
Normal 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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user