diff options
-rw-r--r-- | build.gradle | 1 | ||||
-rw-r--r-- | src/main/java/org/traccar/MainModule.java | 11 | ||||
-rw-r--r-- | src/main/java/org/traccar/api/resource/ServerResource.java | 7 | ||||
-rw-r--r-- | src/main/java/org/traccar/api/resource/SessionResource.java | 25 | ||||
-rw-r--r-- | src/main/java/org/traccar/api/resource/UserResource.java | 8 | ||||
-rw-r--r-- | src/main/java/org/traccar/api/security/LoginService.java | 24 | ||||
-rw-r--r-- | src/main/java/org/traccar/config/Keys.java | 64 | ||||
-rw-r--r-- | src/main/java/org/traccar/database/OpenIdProvider.java | 172 | ||||
-rw-r--r-- | src/main/java/org/traccar/helper/model/UserUtil.java | 10 | ||||
-rw-r--r-- | src/main/java/org/traccar/model/Server.java | 24 | ||||
-rw-r--r-- | swagger.json | 44 |
11 files changed, 381 insertions, 9 deletions
diff --git a/build.gradle b/build.gradle index b27403861..c29f3ba26 100644 --- a/build.gradle +++ b/build.gradle @@ -86,6 +86,7 @@ dependencies { implementation "com.hivemq:hivemq-mqtt-client:1.3.0" implementation "redis.clients:jedis:4.3.1" implementation "com.google.firebase:firebase-admin:9.1.1" + implementation "com.nimbusds:oauth2-oidc-sdk:10.7.1" testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" testImplementation "org.junit.jupiter:junit-jupiter-engine:$junitVersion" testImplementation "org.mockito:mockito-core:5.1.1" diff --git a/src/main/java/org/traccar/MainModule.java b/src/main/java/org/traccar/MainModule.java index 663747de1..51097511a 100644 --- a/src/main/java/org/traccar/MainModule.java +++ b/src/main/java/org/traccar/MainModule.java @@ -33,6 +33,7 @@ import org.traccar.broadcast.NullBroadcastService; import org.traccar.config.Config; import org.traccar.config.Keys; import org.traccar.database.LdapProvider; +import org.traccar.database.OpenIdProvider; import org.traccar.database.StatisticsManager; import org.traccar.forward.EventForwarder; import org.traccar.forward.EventForwarderJson; @@ -87,6 +88,7 @@ import org.traccar.storage.DatabaseStorage; import org.traccar.storage.MemoryStorage; import org.traccar.storage.Storage; import org.traccar.web.WebServer; +import org.traccar.api.security.LoginService; import javax.annotation.Nullable; import javax.inject.Singleton; @@ -170,6 +172,15 @@ public class MainModule extends AbstractModule { return null; } + @Singleton + @Provides + public static OpenIdProvider provideOpenIDProvider(Config config, LoginService loginService) { + if (config.hasKey(Keys.OPENID_CLIENTID)) { + return new OpenIdProvider(config, loginService); + } + return null; + } + @Provides public static WebServer provideWebServer(Injector injector, Config config) { if (config.hasKey(Keys.WEB_PORT)) { diff --git a/src/main/java/org/traccar/api/resource/ServerResource.java b/src/main/java/org/traccar/api/resource/ServerResource.java index 4b7ee9189..6a3b8919e 100644 --- a/src/main/java/org/traccar/api/resource/ServerResource.java +++ b/src/main/java/org/traccar/api/resource/ServerResource.java @@ -16,6 +16,7 @@ package org.traccar.api.resource; import org.traccar.api.BaseResource; +import org.traccar.database.OpenIdProvider; import org.traccar.helper.model.UserUtil; import org.traccar.mail.MailManager; import org.traccar.geocoder.Geocoder; @@ -57,6 +58,10 @@ public class ServerResource extends BaseResource { @Inject @Nullable + private OpenIdProvider openIdProvider; + + @Inject + @Nullable private Geocoder geocoder; @PermitAll @@ -65,6 +70,8 @@ public class ServerResource extends BaseResource { Server server = storage.getObject(Server.class, new Request(new Columns.All())); server.setEmailEnabled(mailManager.getEmailEnabled()); server.setGeocoderEnabled(geocoder != null); + server.setOpenIdEnabled(openIdProvider != null); + server.setOpenIdForce(openIdProvider != null && openIdProvider.getForce()); User user = permissionsService.getUser(getUserId()); if (user != null) { if (user.getAdministrator()) { diff --git a/src/main/java/org/traccar/api/resource/SessionResource.java b/src/main/java/org/traccar/api/resource/SessionResource.java index ff84c135f..ac39fa449 100644 --- a/src/main/java/org/traccar/api/resource/SessionResource.java +++ b/src/main/java/org/traccar/api/resource/SessionResource.java @@ -18,6 +18,7 @@ package org.traccar.api.resource; import org.traccar.api.BaseResource; import org.traccar.api.security.LoginService; import org.traccar.api.signature.TokenManager; +import org.traccar.database.OpenIdProvider; import org.traccar.helper.DataConverter; import org.traccar.helper.LogAction; import org.traccar.helper.ServletHelper; @@ -27,6 +28,8 @@ import org.traccar.storage.query.Columns; import org.traccar.storage.query.Condition; import org.traccar.storage.query.Request; +import com.nimbusds.oauth2.sdk.ParseException; +import javax.annotation.Nullable; import javax.annotation.security.PermitAll; import javax.inject.Inject; import javax.servlet.http.Cookie; @@ -49,6 +52,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.util.Date; +import java.net.URI; @Path("session") @Produces(MediaType.APPLICATION_JSON) @@ -63,6 +67,10 @@ public class SessionResource extends BaseResource { private LoginService loginService; @Inject + @Nullable + private OpenIdProvider openIdProvider; + + @Inject private TokenManager tokenManager; @Context @@ -160,4 +168,21 @@ public class SessionResource extends BaseResource { return tokenManager.generateToken(getUserId(), expiration); } + @PermitAll + @Path("openid/auth") + @GET + public Response openIdAuth() throws IOException { + return Response.seeOther(openIdProvider.createAuthUri()).build(); + } + + @PermitAll + @Path("openid/callback") + @GET + public Response requestToken() throws IOException, StorageException, ParseException, GeneralSecurityException { + StringBuilder requestUrl = new StringBuilder(request.getRequestURL().toString()); + String queryString = request.getQueryString(); + String requestUri = requestUrl.append('?').append(queryString).toString(); + + return Response.seeOther(openIdProvider.handleCallback(URI.create(requestUri), request)).build(); + } } diff --git a/src/main/java/org/traccar/api/resource/UserResource.java b/src/main/java/org/traccar/api/resource/UserResource.java index e41ebbe61..19d88782f 100644 --- a/src/main/java/org/traccar/api/resource/UserResource.java +++ b/src/main/java/org/traccar/api/resource/UserResource.java @@ -17,7 +17,6 @@ package org.traccar.api.resource; import org.traccar.api.BaseObjectResource; import org.traccar.config.Config; -import org.traccar.config.Keys; import org.traccar.helper.LogAction; import org.traccar.helper.model.UserUtil; import org.traccar.model.ManagedUser; @@ -39,7 +38,6 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.util.Collection; -import java.util.Date; @Path("users") @Produces(MediaType.APPLICATION_JSON) @@ -91,11 +89,7 @@ public class UserResource extends BaseObjectResource<User> { if (!permissionsService.getServer().getRegistration()) { throw new SecurityException("Registration disabled"); } - entity.setDeviceLimit(config.getInteger(Keys.USERS_DEFAULT_DEVICE_LIMIT)); - int expirationDays = config.getInteger(Keys.USERS_DEFAULT_EXPIRATION_DAYS); - if (expirationDays > 0) { - entity.setExpirationTime(new Date(System.currentTimeMillis() + expirationDays * 86400000L)); - } + UserUtil.setUserDefaults(entity, config); } } diff --git a/src/main/java/org/traccar/api/security/LoginService.java b/src/main/java/org/traccar/api/security/LoginService.java index 88bafcfb5..c7482a2e3 100644 --- a/src/main/java/org/traccar/api/security/LoginService.java +++ b/src/main/java/org/traccar/api/security/LoginService.java @@ -19,6 +19,7 @@ import org.traccar.api.signature.TokenManager; import org.traccar.config.Config; import org.traccar.config.Keys; import org.traccar.database.LdapProvider; +import org.traccar.helper.model.UserUtil; import org.traccar.model.User; import org.traccar.storage.Storage; import org.traccar.storage.StorageException; @@ -35,6 +36,7 @@ import java.security.GeneralSecurityException; @Singleton public class LoginService { + private final Config config; private final Storage storage; private final TokenManager tokenManager; private final LdapProvider ldapProvider; @@ -46,6 +48,7 @@ public class LoginService { public LoginService( Config config, Storage storage, TokenManager tokenManager, @Nullable LdapProvider ldapProvider) { this.storage = storage; + this.config = config; this.tokenManager = tokenManager; this.ldapProvider = ldapProvider; serviceAccountToken = config.getString(Keys.WEB_SERVICE_ACCOUNT_TOKEN); @@ -89,6 +92,27 @@ public class LoginService { return null; } + public User login(String email, String name, Boolean administrator) throws StorageException { + User user = storage.getObject(User.class, new Request( + new Columns.All(), + new Condition.Equals("email", email))); + + if (user != null) { + checkUserEnabled(user); + return user; + } else { + user = new User(); + UserUtil.setUserDefaults(user, config); + user.setName(name); + user.setEmail(email); + user.setFixedEmail(true); + user.setAdministrator(administrator); + user.setId(storage.addObject(user, new Request(new Columns.Exclude("id")))); + checkUserEnabled(user); + return user; + } + } + private void checkUserEnabled(User user) throws SecurityException { if (user == null) { throw new SecurityException("Unknown account"); diff --git a/src/main/java/org/traccar/config/Keys.java b/src/main/java/org/traccar/config/Keys.java index c207efb1e..707e9e815 100644 --- a/src/main/java/org/traccar/config/Keys.java +++ b/src/main/java/org/traccar/config/Keys.java @@ -611,6 +611,68 @@ public final class Keys { List.of(KeyType.CONFIG)); /** + * Force OpenID Connect authentication. When enabled, the Traccar login page will be skipped + * and users are redirected to the OpenID Connect provider. + */ + public static final ConfigKey<Boolean> OPENID_FORCE = new BooleanConfigKey( + "openid.force", + List.of(KeyType.CONFIG)); + + /** + * OpenID Connect Client ID. + * This is a unique ID assigned to each application you register with your identity provider. + * Required to enable SSO. + */ + public static final ConfigKey<String> OPENID_CLIENTID = new StringConfigKey( + "openid.clientId", + List.of(KeyType.CONFIG)); + + /** + * OpenID Connect Client Secret. + * This is a secret assigned to each application you register with your identity provider. + * Required to enable SSO. + */ + public static final ConfigKey<String> OPENID_CLIENTSECRET = new StringConfigKey( + "openid.clientSecret", + List.of(KeyType.CONFIG)); + + /** + * OpenID Connect Authorization URL. + * This can usually be found in the documentation of your identity provider or by using the well-known + * configuration endpoint, eg. https://auth.example.com//.well-known/openid-configuration + * Required to enable SSO. + */ + public static final ConfigKey<String> OPENID_AUTHURL = new StringConfigKey( + "openid.authUrl", + List.of(KeyType.CONFIG)); + /** + * OpenID Connect Token URL. + * This can be found in the same ways at openid.authUrl. + * Required to enable SSO. + */ + public static final ConfigKey<String> OPENID_TOKENURL = new StringConfigKey( + "openid.tokenUrl", + List.of(KeyType.CONFIG)); + + /** + * OpenID Connect User Info URL. + * This can be found in the same ways at openid.authUrl. + * Required to enable SSO. + */ + public static final ConfigKey<String> OPENID_USERINFOURL = new StringConfigKey( + "openid.userInfoUrl", + List.of(KeyType.CONFIG)); + + /** + * OpenID Connect group to grant admin access. + * Defaults to admins. + */ + public static final ConfigKey<String> OPENID_ADMINGROUP = new StringConfigKey( + "openid.adminGroup", + List.of(KeyType.CONFIG), + "admins"); + + /** * If no data is reported by a device for the given amount of time, status changes from online to unknown. Value is * in seconds. Default timeout is 10 minutes. */ @@ -1573,7 +1635,7 @@ public final class Keys { List.of(KeyType.CONFIG)); /** - * Public URL for the web app. Used for notification and report link. + * Public URL for the web app. Used for notification, report link and OpenID Connect. * If not provided, Traccar will attempt to get a URL from the server IP address, but it might be a local address. */ public static final ConfigKey<String> WEB_URL = new StringConfigKey( diff --git a/src/main/java/org/traccar/database/OpenIdProvider.java b/src/main/java/org/traccar/database/OpenIdProvider.java new file mode 100644 index 000000000..f5c7eef15 --- /dev/null +++ b/src/main/java/org/traccar/database/OpenIdProvider.java @@ -0,0 +1,172 @@ +/* + * Copyright 2023 Daniel Raper (me@danr.uk) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.traccar.database; + +import org.traccar.config.Config; +import org.traccar.config.Keys; +import org.traccar.api.resource.SessionResource; +import org.traccar.api.security.LoginService; +import org.traccar.model.User; +import org.traccar.storage.StorageException; +import org.traccar.helper.LogAction; +import org.traccar.helper.ServletHelper; + +import java.net.URI; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import com.google.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.AuthorizationCode; +import com.nimbusds.oauth2.sdk.ResponseType; +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.AuthorizationGrant; +import com.nimbusds.oauth2.sdk.TokenRequest; +import com.nimbusds.oauth2.sdk.TokenResponse; +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; +import com.nimbusds.oauth2.sdk.ParseException; +import com.nimbusds.oauth2.sdk.AuthorizationResponse; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.auth.ClientAuthentication; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.oauth2.sdk.id.State; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; +import com.nimbusds.openid.connect.sdk.UserInfoResponse; +import com.nimbusds.openid.connect.sdk.UserInfoRequest; +import com.nimbusds.openid.connect.sdk.AuthenticationRequest; + +import com.nimbusds.openid.connect.sdk.claims.UserInfo; + +public class OpenIdProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(OpenIdProvider.class); + + private final Boolean force; + private final ClientID clientId; + private final ClientAuthentication clientAuth; + private URI callbackUrl; + private URI authUrl; + private URI tokenUrl; + private URI userInfoUrl; + private URI baseUrl; + private final String adminGroup; + + private LoginService loginService; + + @Inject + public OpenIdProvider(Config config, LoginService loginService) { + this.loginService = loginService; + + force = config.getBoolean(Keys.OPENID_FORCE); + clientId = new ClientID(config.getString(Keys.OPENID_CLIENTID)); + clientAuth = new ClientSecretBasic(clientId, new Secret(config.getString(Keys.OPENID_CLIENTSECRET))); + + try { + callbackUrl = new URI(config.getString(Keys.WEB_URL, "") + "/api/session/openid/callback"); + authUrl = new URI(config.getString(Keys.OPENID_AUTHURL, "")); + tokenUrl = new URI(config.getString(Keys.OPENID_TOKENURL, "")); + userInfoUrl = new URI(config.getString(Keys.OPENID_USERINFOURL, "")); + baseUrl = new URI(config.getString(Keys.WEB_URL, "")); + } catch (URISyntaxException error) { + LOGGER.error("Invalid URIs provided in OpenID configuration"); + } + + adminGroup = config.getString(Keys.OPENID_ADMINGROUP); + } + + public URI createAuthUri() { + AuthenticationRequest.Builder request = new AuthenticationRequest.Builder( + new ResponseType("code"), + new Scope("openid", "profile", "email", "groups"), + clientId, + callbackUrl); + + return request.endpointURI(authUrl) + .state(new State()) + .build() + .toURI(); + } + + private OIDCTokenResponse getToken( + AuthorizationCode code) throws IOException, ParseException, GeneralSecurityException { + AuthorizationGrant codeGrant = new AuthorizationCodeGrant(code, callbackUrl); + TokenRequest tokenRequest = new TokenRequest(tokenUrl, clientAuth, codeGrant); + + HTTPResponse tokenResponse = tokenRequest.toHTTPRequest().send(); + TokenResponse token = OIDCTokenResponseParser.parse(tokenResponse); + if (!token.indicatesSuccess()) { + throw new GeneralSecurityException("Unable to authenticate with the OpenID Connect provider."); + } + + return (OIDCTokenResponse) token.toSuccessResponse(); + } + + private UserInfo getUserInfo(BearerAccessToken token) throws IOException, ParseException, GeneralSecurityException { + HTTPResponse httpResponse = new UserInfoRequest(userInfoUrl, token) + .toHTTPRequest() + .send(); + + UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse); + + if (!userInfoResponse.indicatesSuccess()) { + throw new GeneralSecurityException( + "Failed to access OpenID Connect user info endpoint. Please contact your administrator."); + } + + return userInfoResponse.toSuccessResponse().getUserInfo(); + } + + public URI handleCallback( + URI requestUri, HttpServletRequest request + ) throws StorageException, ParseException, IOException, GeneralSecurityException { + AuthorizationResponse response = AuthorizationResponse.parse(requestUri); + + if (!response.indicatesSuccess()) { + throw new GeneralSecurityException(response.toErrorResponse().getErrorObject().getDescription()); + } + + AuthorizationCode authCode = response.toSuccessResponse().getAuthorizationCode(); + + if (authCode == null) { + throw new GeneralSecurityException("Malformed OpenID callback."); + } + + OIDCTokenResponse tokens = getToken(authCode); + + BearerAccessToken bearerToken = tokens.getOIDCTokens().getBearerAccessToken(); + + UserInfo userInfo = getUserInfo(bearerToken); + + User user = loginService.login( + userInfo.getEmailAddress(), userInfo.getName(), + userInfo.getStringListClaim("groups").contains(adminGroup)); + + request.getSession().setAttribute(SessionResource.USER_ID_KEY, user.getId()); + LogAction.login(user.getId(), ServletHelper.retrieveRemoteAddress(request)); + + return baseUrl; + } + + public boolean getForce() { + return force; + } +} diff --git a/src/main/java/org/traccar/helper/model/UserUtil.java b/src/main/java/org/traccar/helper/model/UserUtil.java index 9f93afeae..4b1c404f9 100644 --- a/src/main/java/org/traccar/helper/model/UserUtil.java +++ b/src/main/java/org/traccar/helper/model/UserUtil.java @@ -15,6 +15,8 @@ */ package org.traccar.helper.model; +import org.traccar.config.Config; +import org.traccar.config.Keys; import org.traccar.model.Server; import org.traccar.model.User; import org.traccar.storage.Storage; @@ -23,6 +25,7 @@ import org.traccar.storage.query.Columns; import org.traccar.storage.query.Order; import org.traccar.storage.query.Request; +import java.util.Date; import java.util.TimeZone; public final class UserUtil { @@ -65,4 +68,11 @@ public final class UserUtil { return preference != null ? preference : defaultValue; } + public static void setUserDefaults(User user, Config config) { + user.setDeviceLimit(config.getInteger(Keys.USERS_DEFAULT_DEVICE_LIMIT)); + int expirationDays = config.getInteger(Keys.USERS_DEFAULT_EXPIRATION_DAYS); + if (expirationDays > 0) { + user.setExpirationTime(new Date(System.currentTimeMillis() + expirationDays * 86400000L)); + } + } } diff --git a/src/main/java/org/traccar/model/Server.java b/src/main/java/org/traccar/model/Server.java index 73645721b..b790ca472 100644 --- a/src/main/java/org/traccar/model/Server.java +++ b/src/main/java/org/traccar/model/Server.java @@ -16,6 +16,7 @@ package org.traccar.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + import org.traccar.storage.QueryIgnore; import org.traccar.storage.StorageName; @@ -261,4 +262,27 @@ public class Server extends ExtendedModel implements UserRestrictions { this.newServer = newServer; } + private boolean openIdEnabled; + + @QueryIgnore + public boolean getOpenIdEnabled() { + return openIdEnabled; + } + + @QueryIgnore + public void setOpenIdEnabled(boolean openIdEnabled) { + this.openIdEnabled = openIdEnabled; + } + + private boolean openIdForce; + + @QueryIgnore + public boolean getOpenIdForce() { + return openIdForce; + } + + @QueryIgnore + public void setOpenIdForce(boolean openIdForce) { + this.openIdForce = openIdForce; + } } diff --git a/swagger.json b/swagger.json index 6c1e21d28..cbdc9effd 100644 --- a/swagger.json +++ b/swagger.json @@ -1013,6 +1013,42 @@ } } }, + "/session/openid/auth": { + "get": { + "summary": "Fetch Session information", + "tags": [ + "Session" + ], + "parameters": [ + { + } + ], + "responses": { + "303": { + "description": "Redirect to OpenID Connect identity provider", + "content": { } + } + } + } + }, + "/session/openid/callback": { + "get": { + "summary": "OpenID Callback", + "tags": [ + "Session" + ], + "parameters": [ + { + } + ], + "responses": { + "303": { + "description": "Successful authentication, redirect to homepage", + "content": { } + } + } + } + }, "/users": { "get": { "summary": "Fetch a list of Users", @@ -2769,6 +2805,12 @@ "coordinateFormat": { "type": "string" }, + "openIdEnabled": { + "type": "boolean" + }, + "openIdForce": { + "type": "boolean" + }, "attributes": { "type": "object", "properties": {} @@ -3481,4 +3523,4 @@ } } } -}
\ No newline at end of file +} |