From bd26424acd65ddf4b8ca61128b519f834cb8cfab Mon Sep 17 00:00:00 2001 From: Chance Newkirk Date: Sun, 22 Mar 2026 21:43:08 -0700 Subject: [PATCH] feat: add API token authentication for REST API access --- .../features/src/main/resources/features.xml | 6 + container/karaf/pom.xml | 7 + .../filtered-resources/etc/custom.properties | 1 + .../etc/org.apache.karaf.features.cfg | 1 + .../src/main/liquibase/35.0.0/changelog.xml | 38 +++ docs/modules/development/nav.adoc | 1 + .../development/pages/rest/api-tokens.adoc | 121 +++++++ .../development/pages/rest/rest-api.adoc | 8 + .../configuration/system-properties.adoc | 12 + features/api-tokens/api/pom.xml | 51 +++ .../opennms/features/apitokens/ApiToken.java | 100 ++++++ .../apitokens/ApiTokenCreateRequest.java | 36 +++ .../apitokens/ApiTokenCreateResponse.java | 50 +++ .../features/apitokens/ApiTokenDao.java | 34 ++ .../features/apitokens/ApiTokenService.java | 37 +++ features/api-tokens/impl/pom.xml | 64 ++++ .../apitokens/impl/ApiTokenDaoHibernate.java | 61 ++++ .../apitokens/impl/ApiTokenServiceImpl.java | 208 ++++++++++++ .../META-INF/opennms/component-dao.xml | 23 ++ .../impl/ApiTokenServiceImplTest.java | 140 ++++++++ features/api-tokens/pom.xml | 19 ++ features/api-tokens/shell/pom.xml | 48 +++ .../shell/ApiTokenGenerateCommand.java | 62 ++++ .../apitokens/shell/ApiTokenListCommand.java | 68 ++++ .../shell/ApiTokenRevokeAllCommand.java | 47 +++ .../shell/ApiTokenRevokeCommand.java | 51 +++ features/pom.xml | 1 + features/springframework-security/pom.xml | 5 + .../ApiTokenAuthenticationFilter.java | 94 ++++++ .../ApiTokenAuthenticationFilterTest.java | 165 ++++++++++ opennms-base-assembly/pom.xml | 5 + .../api-tokens.properties | 19 ++ opennms-dao/pom.xml | 5 + .../opennms/applicationContext-shared.xml | 1 + opennms-webapp-rest/pom.xml | 5 + .../web/rest/v2/ApiTokenRestService.java | 184 +++++++++++ .../WEB-INF/menu/menu-template-alt.json | 7 + .../WEB-INF/menu/menu-template-default.json | 7 + .../WEB-INF/menu/menu-template-legacy.json | 7 + .../webapp/WEB-INF/menu/menu-template.json | 7 + .../test/resources/menu/menu-template.json | 7 + .../applicationContext-spring-security.xml | 19 ++ .../webapp/account/selfService/apiTokens.jsp | 205 ++++++++++++ .../main/webapp/account/selfService/index.jsp | 7 +- .../admin/userGroupView/users/apiTokens.jsp | 204 ++++++++++++ .../admin/userGroupView/users/userDetail.jsp | 8 + .../org/opennms/smoketest/ApiTokenIT.java | 306 ++++++++++++++++++ .../Menu/UserSelfServiceMenuItem.vue | 6 +- 48 files changed, 2564 insertions(+), 4 deletions(-) create mode 100644 docs/modules/development/pages/rest/api-tokens.adoc create mode 100644 features/api-tokens/api/pom.xml create mode 100644 features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java create mode 100644 features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java create mode 100644 features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java create mode 100644 features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java create mode 100644 features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java create mode 100644 features/api-tokens/impl/pom.xml create mode 100644 features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java create mode 100644 features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java create mode 100644 features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml create mode 100644 features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java create mode 100644 features/api-tokens/pom.xml create mode 100644 features/api-tokens/shell/pom.xml create mode 100644 features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java create mode 100644 features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java create mode 100644 features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java create mode 100644 features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java create mode 100644 features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java create mode 100644 features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java create mode 100644 opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/ApiTokenRestService.java create mode 100644 opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp create mode 100644 opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp create mode 100644 smoke-test/src/test/java/org/opennms/smoketest/ApiTokenIT.java diff --git a/container/features/src/main/resources/features.xml b/container/features/src/main/resources/features.xml index 49ef95d72b71..c765aa1e57a8 100644 --- a/container/features/src/main/resources/features.xml +++ b/container/features/src/main/resources/features.xml @@ -1212,6 +1212,12 @@ mvn:org.opennms.features.api-layer/org.opennms.features.api-layer.core/${project.version} mvn:org.opennms.features/ui-extension/${project.version} + + + + + mvn:org.opennms.features/org.opennms.features.api-tokens.shell/${project.version} + opennms-dao opennms-core-daemon diff --git a/container/karaf/pom.xml b/container/karaf/pom.xml index 2b522672f24a..c110724928e8 100644 --- a/container/karaf/pom.xml +++ b/container/karaf/pom.xml @@ -80,6 +80,7 @@ opennms-http-whiteboard + opennms-api-tokens @@ -285,6 +286,12 @@ pom provided + + org.opennms.features + org.opennms.features.api-tokens.shell + ${project.version} + provided + junit junit diff --git a/container/karaf/src/main/filtered-resources/etc/custom.properties b/container/karaf/src/main/filtered-resources/etc/custom.properties index a5f758b8296a..7cc7b5cbad8e 100644 --- a/container/karaf/src/main/filtered-resources/etc/custom.properties +++ b/container/karaf/src/main/filtered-resources/etc/custom.properties @@ -1326,6 +1326,7 @@ org.osgi.framework.system.packages.extra=org.apache.karaf.branding,\ org.opennms.core.mate.api;version=${opennms.osgi.version},\ org.opennms.core.utils.url;version=${opennms.osgi.version},\ org.opennms.features.activemq.broker.api;version=${opennms.osgi.version},\ + org.opennms.features.apitokens;version=${opennms.osgi.version},\ org.opennms.features.deviceconfig.persistence.api;version=${opennms.osgi.version},\ org.opennms.features.usageanalytics.api;version=${opennms.osgi.version},\ org.opennms.features.dhcpd;version=${opennms.osgi.version},\ diff --git a/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg b/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg index f9d5ca9f3090..07f0c2dbe0e2 100644 --- a/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg +++ b/container/karaf/src/main/filtered-resources/etc/org.apache.karaf.features.cfg @@ -130,6 +130,7 @@ featuresBoot = ( \ opennms-config-management, \ opennms-scv-rest, \ scv-shell, \ + opennms-api-tokens, \ opennms-karaf-health # Ensure that the 'opennms-karaf-health' feature is installed *last* diff --git a/core/schema/src/main/liquibase/35.0.0/changelog.xml b/core/schema/src/main/liquibase/35.0.0/changelog.xml index 491221bd5c4b..9c707495b6f5 100644 --- a/core/schema/src/main/liquibase/35.0.0/changelog.xml +++ b/core/schema/src/main/liquibase/35.0.0/changelog.xml @@ -189,4 +189,42 @@ constraintName="uk_eventconf_sources_name"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/modules/development/nav.adoc b/docs/modules/development/nav.adoc index a04ccefb9c27..9c0d3dbf0d12 100644 --- a/docs/modules/development/nav.adoc +++ b/docs/modules/development/nav.adoc @@ -23,6 +23,7 @@ * xref:rest/rest-api.adoc[] ** xref:rest/osgi.adoc[] ** xref:rest/implemented.adoc[Interfaces] +*** xref:rest/api-tokens.adoc[] *** xref:rest/acknowledgements.adoc[] *** xref:rest/alarm_statistics.adoc[] *** xref:rest/alarms.adoc[] diff --git a/docs/modules/development/pages/rest/api-tokens.adoc b/docs/modules/development/pages/rest/api-tokens.adoc new file mode 100644 index 000000000000..1fdfd458cc33 --- /dev/null +++ b/docs/modules/development/pages/rest/api-tokens.adoc @@ -0,0 +1,121 @@ + += API Tokens + +API tokens provide long-lived bearer token authentication for programmatic REST API access. +Tokens are generated once, displayed once at creation, and stored as SHA-256 hashes. +Token authentication inherits the user's full role set, matching existing basic auth behavior. + +== Authentication + +Include the token in the `Authorization` header: + +[source,bash] +---- +curl -H "Authorization: Bearer onms_" http://localhost:8980/opennms/api/v2/apiTokens +---- + +== GETs (reading data) + +[caption=] +.API Tokens GET functions +[cols="1,3"] +|=== +| Resource | Description + +| /api/v2/apiTokens +| List the authenticated user's tokens. +Returns id, description, createdAt, expiresAt, and lastUsedAt for each token. +The token hash is never included in responses. + +| /api/v2/apiTokens?username=\{user} +| List another user's tokens (requires ROLE_ADMIN). +|=== + +== POSTs (creating data) + +[caption=] +.API Tokens POST functions +[cols="1,3"] +|=== +| Resource | Description + +| /api/v2/apiTokens +| Create a token for the authenticated user. +Accepts JSON body with optional `description` (string, max 256 chars) and `expiresInDays` (integer). +Returns 201 with the plaintext token, which is shown only once. + +| /api/v2/apiTokens?username=\{user} +| Create a token for another user (requires ROLE_ADMIN). +The admin must transmit the plaintext token to the target user out-of-band. +|=== + +.Example create request +[source,json] +---- +{ + "description": "grafana integration", + "expiresInDays": 90 +} +---- + +.Example create response +[source,json] +---- +{ + "id": 1, + "token": "onms_a1b2c3d4...", + "description": "grafana integration", + "createdAt": "2026-03-22T10:00:00.000Z", + "expiresAt": "2026-06-20T10:00:00.000Z" +} +---- + +== DELETEs (removing data) + +[caption=] +.API Tokens DELETE functions +[cols="1,3"] +|=== +| Resource | Description + +| /api/v2/apiTokens/\{id} +| Revoke a specific token. +Users can revoke their own tokens; admins can revoke any token. +Returns 204 on success, 404 if not found or not authorized. + +| /api/v2/apiTokens?username=\{user} +| Revoke all tokens for a user. +Users can revoke their own; admins can revoke any user's tokens (requires ROLE_ADMIN for other users). +Returns 204 on success. +|=== + +== Karaf Shell Commands + +[cols="1,3"] +|=== +| Command | Description + +| `opennms:api-token-generate [-d description] [-e days] ` +| Generate a new token. Prints the plaintext token once. + +| `opennms:api-token-list ` +| List all tokens for a user. + +| `opennms:api-token-revoke ` +| Revoke a specific token by ID. + +| `opennms:api-token-revoke-all ` +| Revoke all tokens for a user. +|=== + +NOTE: In the Karaf shell, options (`-d`, `-e`) must come before the positional argument. + +== Configuration + +See xref:reference:configuration/system-properties.adoc[System Properties] for configurable limits: + +* `org.opennms.api.tokens.max-expiry-days` (default: 365) +* `org.opennms.api.tokens.default-expiry-days` (default: 365) +* `org.opennms.api.tokens.max-tokens-per-user` (default: 50) + +These can be set in `$\{OPENNMS_HOME}/etc/opennms.properties.d/api-tokens.properties`. diff --git a/docs/modules/development/pages/rest/rest-api.adoc b/docs/modules/development/pages/rest/rest-api.adoc index 8ebc7ad5ee00..c9e5958548cf 100644 --- a/docs/modules/development/pages/rest/rest-api.adoc +++ b/docs/modules/development/pages/rest/rest-api.adoc @@ -14,6 +14,14 @@ For instance, `http://localhost:8980/opennms/rest/alarms/` will give you the cur Use HTTP basic authentication to provide a valid username and password. By default, you will not receive a challenge, so you must configure your REST client library to send basic authentication proactively. +Alternatively, you can use xref:rest/api-tokens.adoc[API tokens] for programmatic access. +API tokens are long-lived bearer tokens that avoid exposing your password: + +[source,bash] +---- +curl -H "Authorization: Bearer onms_" http://localhost:8980/opennms/api/v2/info +---- + == Data format Jersey enables {page-component-title} to create REST calls using either XML or JSON. diff --git a/docs/modules/reference/pages/configuration/system-properties.adoc b/docs/modules/reference/pages/configuration/system-properties.adoc index fb3366f0da59..9587b62ddf6b 100644 --- a/docs/modules/reference/pages/configuration/system-properties.adoc +++ b/docs/modules/reference/pages/configuration/system-properties.adoc @@ -367,6 +367,18 @@ Many of the properties here are documented with much greater detail in their rel | false | Enable or disable exporting performance data to an external system over a TCP port +| `org.opennms.api.tokens.default-expiry-days` +| 365 +| Default expiry period in days for newly created API tokens when no expiry is specified + +| `org.opennms.api.tokens.max-expiry-days` +| 365 +| Maximum allowed expiry period in days for API tokens. Set to `0` to disable token creation entirely. + +| `org.opennms.api.tokens.max-tokens-per-user` +| 50 +| Maximum number of active API tokens each user is allowed to have + | `org.opennms.security.disableLoginSuccessEvent` | false | Enable or disable sending successful login events on a successful login to the webui diff --git a/features/api-tokens/api/pom.xml b/features/api-tokens/api/pom.xml new file mode 100644 index 000000000000..a0bdc03cec01 --- /dev/null +++ b/features/api-tokens/api/pom.xml @@ -0,0 +1,51 @@ + + + + org.opennms.features + org.opennms.features.api-tokens + 35.0.5-SNAPSHOT + + 4.0.0 + org.opennms.features.api-tokens.api + bundle + OpenNMS :: Features :: API Tokens :: API + + + + org.apache.felix + maven-bundle-plugin + true + + + JavaSE-17 + ${project.groupId}.${project.artifactId} + ${project.version} + + + + + + + + org.opennms + opennms-dao-api + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + org.hibernate.javax.persistence + hibernate-jpa-2.0-api + provided + + + org.opennms.dependencies + jaxb-dependencies + pom + provided + + + diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java new file mode 100644 index 000000000000..74446005bfa5 --- /dev/null +++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiToken.java @@ -0,0 +1,100 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens; + +import java.io.Serializable; +import java.util.Date; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.SequenceGenerator; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement(name = "apiToken") +@Entity +@Table(name = "api_tokens") +public class ApiToken implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "apiTokensSeq") + @SequenceGenerator(name = "apiTokensSeq", sequenceName = "api_tokens_id_seq", allocationSize = 1) + private Integer id; + + @com.fasterxml.jackson.annotation.JsonIgnore + @javax.xml.bind.annotation.XmlTransient + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + @XmlElement + @Column(name = "username", nullable = false, length = 256) + private String username; + + @XmlElement + @Column(name = "description", length = 256) + private String description; + + @XmlElement + @Column(name = "created_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdAt; + + @XmlElement + @Column(name = "expires_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date expiresAt; + + @XmlElement + @Column(name = "last_used_at") + @Temporal(TemporalType.TIMESTAMP) + private Date lastUsedAt; + + public ApiToken() {} + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public String getTokenHash() { return tokenHash; } + public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } + + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + + public Date getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(Date lastUsedAt) { this.lastUsedAt = lastUsedAt; } +} diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java new file mode 100644 index 000000000000..c2db214be4b3 --- /dev/null +++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateRequest.java @@ -0,0 +1,36 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens; + +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement +public class ApiTokenCreateRequest { + private String description; + private Integer expiresInDays; + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Integer getExpiresInDays() { return expiresInDays; } + public void setExpiresInDays(Integer expiresInDays) { this.expiresInDays = expiresInDays; } +} diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java new file mode 100644 index 000000000000..6f7a2b48ffee --- /dev/null +++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenCreateResponse.java @@ -0,0 +1,50 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens; + +import java.util.Date; + +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement +public class ApiTokenCreateResponse { + private Integer id; + private String token; + private String description; + private Date createdAt; + private Date expiresAt; + + public Integer getId() { return id; } + public void setId(Integer id) { this.id = id; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } + + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } +} diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java new file mode 100644 index 000000000000..1bb0fa2b1935 --- /dev/null +++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenDao.java @@ -0,0 +1,34 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens; + +import java.util.List; + +import org.opennms.netmgt.dao.api.OnmsDao; + +public interface ApiTokenDao extends OnmsDao { + ApiToken findByTokenHash(String tokenHash); + List findByUsername(String username); + int countByUsername(String username); + void deleteByUsername(String username); + String findUsernameByTokenId(Integer tokenId); +} diff --git a/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java new file mode 100644 index 000000000000..4f6e099e35cc --- /dev/null +++ b/features/api-tokens/api/src/main/java/org/opennms/features/apitokens/ApiTokenService.java @@ -0,0 +1,37 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens; + +import java.util.List; + +/** + * Service for managing API tokens. The {@code authenticate} method is called + * by the Spring Security filter on every request bearing a token. + */ +public interface ApiTokenService { + String authenticate(String plaintextToken); + ApiTokenCreateResponse createToken(String username, String description, Integer expiresInDays); + List listTokens(String username); + boolean revokeToken(Integer tokenId); + int revokeAllTokens(String username); + String getTokenOwner(Integer tokenId); +} diff --git a/features/api-tokens/impl/pom.xml b/features/api-tokens/impl/pom.xml new file mode 100644 index 000000000000..1e55a7bc2da0 --- /dev/null +++ b/features/api-tokens/impl/pom.xml @@ -0,0 +1,64 @@ + + + + org.opennms.features + org.opennms.features.api-tokens + 35.0.5-SNAPSHOT + + 4.0.0 + org.opennms.features.api-tokens.impl + bundle + OpenNMS :: Features :: API Tokens :: Impl + + + + org.apache.felix + maven-bundle-plugin + true + + + JavaSE-17 + ${project.groupId}.${project.artifactId} + ${project.version} + + + + + + + + org.opennms.features + org.opennms.features.api-tokens.api + ${project.version} + + + org.opennms + opennms-dao-api + + + org.opennms + opennms-dao + + + org.opennms.dependencies + spring-dependencies + pom + + + org.opennms.core + org.opennms.core.sysprops + ${project.version} + + + junit + junit + test + + + org.mockito + mockito-inline + test + + + diff --git a/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java new file mode 100644 index 000000000000..94a26a7b7056 --- /dev/null +++ b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenDaoHibernate.java @@ -0,0 +1,61 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.impl; + +import java.util.List; + +import org.opennms.features.apitokens.ApiToken; +import org.opennms.features.apitokens.ApiTokenDao; +import org.opennms.netmgt.dao.hibernate.AbstractDaoHibernate; + +public class ApiTokenDaoHibernate extends AbstractDaoHibernate implements ApiTokenDao { + + public ApiTokenDaoHibernate() { + super(ApiToken.class); + } + + @Override + public ApiToken findByTokenHash(String tokenHash) { + return findUnique("from ApiToken where tokenHash = ?", tokenHash); + } + + @Override + public List findByUsername(String username) { + return find("from ApiToken where username = ? order by createdAt desc", username); + } + + @Override + public int countByUsername(String username) { + return queryInt("select count(*) from ApiToken where username = ?", username); + } + + @Override + public void deleteByUsername(String username) { + getHibernateTemplate().bulkUpdate("delete from ApiToken where username = ?", username); + } + + @Override + public String findUsernameByTokenId(Integer tokenId) { + ApiToken token = get(tokenId); + return token != null ? token.getUsername() : null; + } +} diff --git a/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java new file mode 100644 index 000000000000..8d88769da816 --- /dev/null +++ b/features/api-tokens/impl/src/main/java/org/opennms/features/apitokens/impl/ApiTokenServiceImpl.java @@ -0,0 +1,208 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.impl; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.opennms.features.apitokens.ApiToken; +import org.opennms.features.apitokens.ApiTokenCreateResponse; +import org.opennms.features.apitokens.ApiTokenDao; +import org.opennms.features.apitokens.ApiTokenService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.transaction.annotation.Transactional; + +public class ApiTokenServiceImpl implements ApiTokenService { + private static final Logger LOG = LoggerFactory.getLogger(ApiTokenServiceImpl.class); + private static final String TOKEN_PREFIX = "onms_"; + private static final long LAST_USED_DEBOUNCE_MS = 5 * 60 * 1000; // 5 minutes + private static final int MAX_DEBOUNCE_ENTRIES = 10_000; + private static final int MAX_CREATION_LOCK_ENTRIES = 10_000; + + private final SecureRandom secureRandom = new SecureRandom(); + private final Map lastUsedUpdateTimes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap creationLocks = new ConcurrentHashMap<>(); + + private ApiTokenDao apiTokenDao; + private int maxExpiryDays = 365; + private int defaultExpiryDays = 365; + private int maxTokensPerUser = 50; + + @Override + @Transactional + public String authenticate(String plaintextToken) { + if (plaintextToken == null || !plaintextToken.startsWith(TOKEN_PREFIX)) { + return null; + } + String hash = sha256Hex(plaintextToken); + ApiToken token = apiTokenDao.findByTokenHash(hash); + if (token == null) { + return null; + } + if (token.getExpiresAt().before(new Date())) { + return null; + } + // Debounced last_used_at update + Long lastUpdate = lastUsedUpdateTimes.get(token.getId()); + long now = System.currentTimeMillis(); + if (lastUpdate == null || (now - lastUpdate) > LAST_USED_DEBOUNCE_MS) { + token.setLastUsedAt(new Date()); + apiTokenDao.saveOrUpdate(token); + lastUsedUpdateTimes.put(token.getId(), now); + if (lastUsedUpdateTimes.size() > MAX_DEBOUNCE_ENTRIES) { + lastUsedUpdateTimes.clear(); + } + } + return token.getUsername(); + } + + @Override + @Transactional + public ApiTokenCreateResponse createToken(String username, String description, Integer expiresInDays) { + if (maxExpiryDays == 0) { + throw new IllegalStateException("API token creation is disabled"); + } + int days = expiresInDays != null ? expiresInDays : defaultExpiryDays; + if (days > maxExpiryDays) { + throw new IllegalArgumentException("Requested expiry exceeds the allowed maximum"); + } + if (days <= 0) { + throw new IllegalArgumentException("Expiry must be positive"); + } + if (description != null && description.length() > 256) { + throw new IllegalArgumentException("Description must be 256 characters or less"); + } + + // Per-user lock to prevent TOCTOU race on maxTokensPerUser + Lock lock = creationLocks.computeIfAbsent(username, k -> new ReentrantLock()); + if (creationLocks.size() > MAX_CREATION_LOCK_ENTRIES) { + creationLocks.clear(); + } + lock.lock(); + try { + int count = apiTokenDao.countByUsername(username); + if (count >= maxTokensPerUser) { + throw new IllegalStateException("Maximum number of tokens reached for user"); + } + + // Generate token + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + String plaintextToken = TOKEN_PREFIX + bytesToHex(randomBytes); + String hash = sha256Hex(plaintextToken); + + Instant now = Instant.now(); + ApiToken token = new ApiToken(); + token.setTokenHash(hash); + token.setUsername(username); + token.setDescription(description); + token.setCreatedAt(Date.from(now)); + token.setExpiresAt(Date.from(now.plus(days, ChronoUnit.DAYS))); + + apiTokenDao.save(token); + apiTokenDao.flush(); + + LOG.info("API token created for user {} (id={}, expires={})", username, token.getId(), token.getExpiresAt()); + + ApiTokenCreateResponse response = new ApiTokenCreateResponse(); + response.setId(token.getId()); + response.setToken(plaintextToken); + response.setDescription(description); + response.setCreatedAt(token.getCreatedAt()); + response.setExpiresAt(token.getExpiresAt()); + return response; + } finally { + lock.unlock(); + } + } + + @Override + @Transactional(readOnly = true) + public List listTokens(String username) { + return apiTokenDao.findByUsername(username); + } + + @Override + @Transactional + public boolean revokeToken(Integer tokenId) { + ApiToken token = apiTokenDao.get(tokenId); + if (token == null) { + return false; + } + LOG.info("API token revoked for user {} (id={})", token.getUsername(), tokenId); + apiTokenDao.delete(token); + lastUsedUpdateTimes.remove(tokenId); + return true; + } + + @Override + @Transactional(readOnly = true) + public String getTokenOwner(Integer tokenId) { + return apiTokenDao.findUsernameByTokenId(tokenId); + } + + @Override + @Transactional + public int revokeAllTokens(String username) { + int count = apiTokenDao.countByUsername(username); + if (count > 0) { + apiTokenDao.deleteByUsername(username); + lastUsedUpdateTimes.clear(); + LOG.info("All {} API tokens revoked for user {}", count, username); + } + return count; + } + + static String sha256Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + public void setApiTokenDao(ApiTokenDao apiTokenDao) { this.apiTokenDao = apiTokenDao; } + public void setMaxExpiryDays(int maxExpiryDays) { this.maxExpiryDays = maxExpiryDays; } + public void setDefaultExpiryDays(int defaultExpiryDays) { this.defaultExpiryDays = defaultExpiryDays; } + public void setMaxTokensPerUser(int maxTokensPerUser) { this.maxTokensPerUser = maxTokensPerUser; } +} diff --git a/features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml b/features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml new file mode 100644 index 000000000000..fe41c32d7454 --- /dev/null +++ b/features/api-tokens/impl/src/main/resources/META-INF/opennms/component-dao.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java b/features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java new file mode 100644 index 000000000000..959c5f5a21e2 --- /dev/null +++ b/features/api-tokens/impl/src/test/java/org/opennms/features/apitokens/impl/ApiTokenServiceImplTest.java @@ -0,0 +1,140 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.impl; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.junit.Before; +import org.junit.Test; +import org.opennms.features.apitokens.ApiToken; +import org.opennms.features.apitokens.ApiTokenCreateResponse; +import org.opennms.features.apitokens.ApiTokenDao; + +import java.util.Date; + +public class ApiTokenServiceImplTest { + private ApiTokenServiceImpl service; + private ApiTokenDao mockDao; + + @Before + public void setUp() { + mockDao = mock(ApiTokenDao.class); + service = new ApiTokenServiceImpl(); + service.setApiTokenDao(mockDao); + service.setMaxExpiryDays(365); + service.setDefaultExpiryDays(90); + service.setMaxTokensPerUser(50); + } + + @Test + public void testCreateTokenReturnsPlaintext() { + when(mockDao.countByUsername("admin")).thenReturn(0); + when(mockDao.save(any(ApiToken.class))).thenReturn(1); + + ApiTokenCreateResponse response = service.createToken("admin", "test token", 30); + + assertNotNull(response.getToken()); + assertTrue(response.getToken().startsWith("onms_")); + assertEquals("test token", response.getDescription()); + assertNotNull(response.getCreatedAt()); + assertNotNull(response.getExpiresAt()); + verify(mockDao).save(any(ApiToken.class)); + } + + @Test + public void testCreateTokenUsesDefaultExpiry() { + when(mockDao.countByUsername("admin")).thenReturn(0); + when(mockDao.save(any(ApiToken.class))).thenReturn(1); + + ApiTokenCreateResponse response = service.createToken("admin", "test", null); + + long diffMs = response.getExpiresAt().getTime() - response.getCreatedAt().getTime(); + long diffDays = diffMs / (1000 * 60 * 60 * 24); + assertEquals(90, diffDays); + } + + @Test(expected = IllegalArgumentException.class) + public void testCreateTokenRejectsExcessiveExpiry() { + when(mockDao.countByUsername("admin")).thenReturn(0); + service.createToken("admin", "test", 999); + } + + @Test(expected = IllegalArgumentException.class) + public void testCreateTokenRejectsLongDescription() { + when(mockDao.countByUsername("admin")).thenReturn(0); + String longDesc = "a".repeat(257); + service.createToken("admin", longDesc, 30); + } + + @Test(expected = IllegalStateException.class) + public void testCreateTokenRejectsWhenMaxTokensReached() { + when(mockDao.countByUsername("admin")).thenReturn(50); + service.createToken("admin", "test", 30); + } + + @Test + public void testAuthenticateValidToken() { + String plaintext = "onms_abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678"; + String hash = ApiTokenServiceImpl.sha256Hex(plaintext); + + ApiToken token = new ApiToken(); + token.setId(1); + token.setTokenHash(hash); + token.setUsername("admin"); + token.setExpiresAt(new Date(System.currentTimeMillis() + 86400000)); + + when(mockDao.findByTokenHash(hash)).thenReturn(token); + + String result = service.authenticate(plaintext); + assertEquals("admin", result); + } + + @Test + public void testAuthenticateExpiredToken() { + String plaintext = "onms_abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678"; + String hash = ApiTokenServiceImpl.sha256Hex(plaintext); + + ApiToken token = new ApiToken(); + token.setId(1); + token.setTokenHash(hash); + token.setUsername("admin"); + token.setExpiresAt(new Date(System.currentTimeMillis() - 86400000)); + + when(mockDao.findByTokenHash(hash)).thenReturn(token); + + assertNull(service.authenticate(plaintext)); + } + + @Test + public void testAuthenticateInvalidToken() { + when(mockDao.findByTokenHash(anyString())).thenReturn(null); + assertNull(service.authenticate("onms_doesnotexist")); + } + + @Test + public void testAuthenticateNullAndNonPrefixed() { + assertNull(service.authenticate(null)); + assertNull(service.authenticate("not_a_token")); + } +} diff --git a/features/api-tokens/pom.xml b/features/api-tokens/pom.xml new file mode 100644 index 000000000000..dff8837958ba --- /dev/null +++ b/features/api-tokens/pom.xml @@ -0,0 +1,19 @@ + + + + org.opennms + org.opennms.features + 35.0.5-SNAPSHOT + + 4.0.0 + org.opennms.features + org.opennms.features.api-tokens + pom + OpenNMS :: Features :: API Tokens + + api + impl + shell + + diff --git a/features/api-tokens/shell/pom.xml b/features/api-tokens/shell/pom.xml new file mode 100644 index 000000000000..5dc15110522e --- /dev/null +++ b/features/api-tokens/shell/pom.xml @@ -0,0 +1,48 @@ + + + + org.opennms.features + org.opennms.features.api-tokens + 35.0.5-SNAPSHOT + + 4.0.0 + org.opennms.features.api-tokens.shell + bundle + OpenNMS :: Features :: API Tokens :: Shell + + + + org.apache.felix + maven-bundle-plugin + true + + + JavaSE-17 + ${project.groupId}.${project.artifactId} + ${project.version} + org.opennms.features.apitokens.shell + + + + + + + + org.opennms.features + org.opennms.features.api-tokens.api + ${project.version} + + + org.apache.karaf.shell + org.apache.karaf.shell.core + ${karafVersion} + provided + + + org.osgi + osgi.core + provided + + + diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java new file mode 100644 index 000000000000..fa1581d98c97 --- /dev/null +++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenGenerateCommand.java @@ -0,0 +1,62 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.shell; + +import org.apache.karaf.shell.api.action.Action; +import org.apache.karaf.shell.api.action.Argument; +import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.Option; +import org.apache.karaf.shell.api.action.lifecycle.Reference; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.opennms.features.apitokens.ApiTokenCreateResponse; +import org.opennms.features.apitokens.ApiTokenService; + +@Command(scope = "opennms", name = "api-token-generate", description = "Generate an API token for a user") +@Service +public class ApiTokenGenerateCommand implements Action { + + @Reference + private ApiTokenService apiTokenService; + + @Argument(index = 0, name = "username", description = "Username to generate token for", required = true) + private String username; + + @Option(name = "--description", aliases = {"-d"}, description = "Token description") + private String description; + + @Option(name = "--expiry-days", aliases = {"-e"}, description = "Token expiry in days") + private Integer expiryDays; + + @Override + public Object execute() throws Exception { + ApiTokenCreateResponse response = apiTokenService.createToken(username, description, expiryDays); + System.out.println("Token generated successfully."); + System.out.println("Token: " + response.getToken()); + System.out.println("ID: " + response.getId()); + System.out.println("Description: " + (response.getDescription() != null ? response.getDescription() : "")); + System.out.println("Created: " + response.getCreatedAt()); + System.out.println("Expires: " + response.getExpiresAt()); + System.out.println(); + System.out.println("WARNING: This token will not be shown again. Copy it now."); + return null; + } +} diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java new file mode 100644 index 000000000000..50934c0e7460 --- /dev/null +++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenListCommand.java @@ -0,0 +1,68 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.shell; + +import java.util.List; + +import org.apache.karaf.shell.api.action.Action; +import org.apache.karaf.shell.api.action.Argument; +import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.lifecycle.Reference; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.opennms.features.apitokens.ApiToken; +import org.opennms.features.apitokens.ApiTokenService; + +@Command(scope = "opennms", name = "api-token-list", description = "List API tokens for a user") +@Service +public class ApiTokenListCommand implements Action { + + @Reference + private ApiTokenService apiTokenService; + + @Argument(index = 0, name = "username", description = "Username to list tokens for", required = true) + private String username; + + @Override + public Object execute() throws Exception { + List tokens = apiTokenService.listTokens(username); + if (tokens.isEmpty()) { + System.out.println("No tokens found for user: " + username); + return null; + } + System.out.printf("%-6s %-20s %-24s %-24s %-24s%n", "ID", "Description", "Created", "Expires", "Last Used"); + System.out.println("-".repeat(100)); + for (ApiToken token : tokens) { + System.out.printf("%-6d %-20s %-24s %-24s %-24s%n", + token.getId(), + truncate(token.getDescription(), 20), + token.getCreatedAt(), + token.getExpiresAt(), + token.getLastUsedAt() != null ? token.getLastUsedAt() : "never"); + } + return null; + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max - 3) + "..."; + } +} diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java new file mode 100644 index 000000000000..d4f12a8a9458 --- /dev/null +++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeAllCommand.java @@ -0,0 +1,47 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.shell; + +import org.apache.karaf.shell.api.action.Action; +import org.apache.karaf.shell.api.action.Argument; +import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.lifecycle.Reference; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.opennms.features.apitokens.ApiTokenService; + +@Command(scope = "opennms", name = "api-token-revoke-all", description = "Revoke all API tokens for a user") +@Service +public class ApiTokenRevokeAllCommand implements Action { + + @Reference + private ApiTokenService apiTokenService; + + @Argument(index = 0, name = "username", description = "Username to revoke all tokens for", required = true) + private String username; + + @Override + public Object execute() throws Exception { + int count = apiTokenService.revokeAllTokens(username); + System.out.println("Revoked " + count + " token(s) for user: " + username); + return null; + } +} diff --git a/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java new file mode 100644 index 000000000000..464a406a60d3 --- /dev/null +++ b/features/api-tokens/shell/src/main/java/org/opennms/features/apitokens/shell/ApiTokenRevokeCommand.java @@ -0,0 +1,51 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.features.apitokens.shell; + +import org.apache.karaf.shell.api.action.Action; +import org.apache.karaf.shell.api.action.Argument; +import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.lifecycle.Reference; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.opennms.features.apitokens.ApiTokenService; + +@Command(scope = "opennms", name = "api-token-revoke", description = "Revoke a specific API token by ID") +@Service +public class ApiTokenRevokeCommand implements Action { + + @Reference + private ApiTokenService apiTokenService; + + @Argument(index = 0, name = "token-id", description = "Token ID to revoke", required = true) + private Integer tokenId; + + @Override + public Object execute() throws Exception { + boolean revoked = apiTokenService.revokeToken(tokenId); + if (revoked) { + System.out.println("Token " + tokenId + " revoked."); + } else { + System.out.println("Token " + tokenId + " not found."); + } + return null; + } +} diff --git a/features/pom.xml b/features/pom.xml index 4a34a6132b5c..96f81cb34d90 100644 --- a/features/pom.xml +++ b/features/pom.xml @@ -24,6 +24,7 @@ activemq api-layer + api-tokens alarms diff --git a/features/springframework-security/pom.xml b/features/springframework-security/pom.xml index fc85a71b8f58..b33a25b6460c 100644 --- a/features/springframework-security/pom.xml +++ b/features/springframework-security/pom.xml @@ -77,6 +77,11 @@ org.opennms opennms-web-api + + org.opennms.features + org.opennms.features.api-tokens.api + ${project.version} + org.eclipse.jetty jetty-server diff --git a/features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java b/features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java new file mode 100644 index 000000000000..e4171fd33f2d --- /dev/null +++ b/features/springframework-security/src/main/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilter.java @@ -0,0 +1,94 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.web.springframework.security; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.opennms.features.apitokens.ApiTokenService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Spring Security filter that authenticates requests bearing an API token + * in the Authorization header (Bearer scheme). If the token is valid, + * sets the SecurityContext; otherwise passes through to the next filter. + */ +public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { + private static final Logger LOG = LoggerFactory.getLogger(ApiTokenAuthenticationFilter.class); + private static final String BEARER_PREFIX = "Bearer "; + private static final String TOKEN_PREFIX = "onms_"; + + private ApiTokenService apiTokenService; + private SpringSecurityUserDao userDao; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (SecurityContextHolder.getContext().getAuthentication() == null) { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + String token = authHeader.substring(BEARER_PREFIX.length()).trim(); + if (token.startsWith(TOKEN_PREFIX)) { + if (apiTokenService == null) { + LOG.debug("API token service not injected, skipping token auth"); + } else { + try { + String username = apiTokenService.authenticate(token); + if (username != null) { + SpringSecurityUser user = userDao.getByUsername(username); + if (user != null) { + if (user.getAuthorities().isEmpty()) { + user.addAuthority(SpringSecurityUserDao.ROLE_USER); + } + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + LOG.debug("API token authentication successful for user: {}", username); + } + } + } catch (Exception e) { + // Service unavailable (OSGi proxy, network, etc.) — fall through to basic auth + LOG.debug("API token authentication failed, skipping token auth", e); + } + } + } + } + } + filterChain.doFilter(request, response); + } + + public void setApiTokenService(ApiTokenService apiTokenService) { + this.apiTokenService = apiTokenService; + } + + public void setUserDao(SpringSecurityUserDao userDao) { + this.userDao = userDao; + } +} diff --git a/features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java b/features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java new file mode 100644 index 000000000000..9074e4e25834 --- /dev/null +++ b/features/springframework-security/src/test/java/org/opennms/web/springframework/security/ApiTokenAuthenticationFilterTest.java @@ -0,0 +1,165 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.web.springframework.security; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.opennms.features.apitokens.ApiTokenService; +import org.opennms.netmgt.model.OnmsUser; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; + +public class ApiTokenAuthenticationFilterTest { + private ApiTokenAuthenticationFilter filter; + private ApiTokenService mockService; + private SpringSecurityUserDao mockUserDao; + private HttpServletRequest mockRequest; + private HttpServletResponse mockResponse; + private FilterChain mockChain; + + @Before + public void setUp() { + mockService = mock(ApiTokenService.class); + mockUserDao = mock(SpringSecurityUserDao.class); + mockRequest = mock(HttpServletRequest.class); + mockResponse = mock(HttpServletResponse.class); + mockChain = mock(FilterChain.class); + SecurityContextHolder.clearContext(); + + filter = new ApiTokenAuthenticationFilter(); + filter.setApiTokenService(mockService); + filter.setUserDao(mockUserDao); + } + + @After + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testValidBearerTokenSetsAuthentication() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123"); + when(mockService.authenticate("onms_abc123")).thenReturn("admin"); + + OnmsUser onmsUser = new OnmsUser(); + onmsUser.setUsername("admin"); + SpringSecurityUser user = new SpringSecurityUser(onmsUser); + user.setAuthorities(Collections.singletonList(new SimpleGrantedAuthority("ROLE_ADMIN"))); + when(mockUserDao.getByUsername("admin")).thenReturn(user); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals("admin", ((SpringSecurityUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getUsername()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } + + @Test + public void testInvalidTokenPassesThrough() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_invalid"); + when(mockService.authenticate("onms_invalid")).thenReturn(null); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } + + @Test + public void testNoBearerHeaderPassesThrough() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn(null); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } + + @Test + public void testBasicAuthHeaderIgnored() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Basic dXNlcjpwYXNz"); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + verifyNoInteractions(mockService); + } + + @Test + public void testServiceUnavailablePassesThrough() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123"); + when(mockService.authenticate("onms_abc123")).thenThrow(new IllegalStateException("Service unavailable")); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } + + @Test + public void testOsgiServiceExceptionPassesThrough() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123"); + when(mockService.authenticate("onms_abc123")).thenThrow(new RuntimeException("OSGi ServiceException")); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } + + @Test + public void testNullServicePassesThrough() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123"); + + ApiTokenAuthenticationFilter nullServiceFilter = new ApiTokenAuthenticationFilter(); + // apiTokenService is null — not set + nullServiceFilter.setUserDao(mockUserDao); + + nullServiceFilter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } + + @Test + public void testDeletedUserPassesThrough() throws Exception { + when(mockRequest.getHeader("Authorization")).thenReturn("Bearer onms_abc123"); + when(mockService.authenticate("onms_abc123")).thenReturn("deleteduser"); + when(mockUserDao.getByUsername("deleteduser")).thenReturn(null); + + filter.doFilterInternal(mockRequest, mockResponse, mockChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(mockChain).doFilter(mockRequest, mockResponse); + } +} diff --git a/opennms-base-assembly/pom.xml b/opennms-base-assembly/pom.xml index 9eaec8b19cc3..69241bc4416b 100644 --- a/opennms-base-assembly/pom.xml +++ b/opennms-base-assembly/pom.xml @@ -828,6 +828,11 @@ org.opennms.features.endpoints.grafana.persistence.impl ${project.version} + + org.opennms.features + org.opennms.features.api-tokens.impl + ${project.version} + org.opennms.features.timeformat org.opennms.features.timeformat.api diff --git a/opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties b/opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties new file mode 100644 index 000000000000..753432712357 --- /dev/null +++ b/opennms-base-assembly/src/main/filtered/etc/opennms.properties.d/api-tokens.properties @@ -0,0 +1,19 @@ +# API Token Authentication +# +# API tokens allow users to generate long-lived bearer tokens for +# programmatic REST API access without exposing their password. +# Tokens are created once, displayed once, and stored as SHA-256 hashes. +# +# Authenticate with: Authorization: Bearer onms_ + +# Maximum allowed token lifetime in days. Set to 0 to disable token creation. +# Default: 365 +#org.opennms.api.tokens.max-expiry-days=365 + +# Default token lifetime in days when no expiry is specified at creation. +# Default: 365 +#org.opennms.api.tokens.default-expiry-days=365 + +# Maximum number of active tokens per user. +# Default: 50 +#org.opennms.api.tokens.max-tokens-per-user=50 diff --git a/opennms-dao/pom.xml b/opennms-dao/pom.xml index c862d8371863..8f9826d45db0 100644 --- a/opennms-dao/pom.xml +++ b/opennms-dao/pom.xml @@ -126,6 +126,11 @@ org.opennms.features.enlinkd org.opennms.features.enlinkd.persistence.api + + org.opennms.features + org.opennms.features.api-tokens.api + ${project.version} + org.opennms opennms-rrd-api diff --git a/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml b/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml index 7b98cf2e0862..0a0b9df9e37e 100644 --- a/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml +++ b/opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml @@ -46,6 +46,7 @@ org.opennms.netmgt.telemetry.protocols.bmp.persistence.api org.opennms.features.deviceconfig.persistence.api org.opennms.features.usageanalytics.api + org.opennms.features.apitokens + + + + + + + @@ -194,6 +204,7 @@ + @@ -480,6 +491,14 @@ + + + + + + + + diff --git a/opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp b/opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp new file mode 100644 index 000000000000..10df4f16f900 --- /dev/null +++ b/opennms-webapp/src/main/webapp/account/selfService/apiTokens.jsp @@ -0,0 +1,205 @@ +<%-- + + Licensed to The OpenNMS Group, Inc (TOG) under one or more + contributor license agreements. See the LICENSE.md file + distributed with this work for additional information + regarding copyright ownership. + + TOG licenses this file to You under the GNU Affero General + Public License Version 3 (the "License") or (at your option) + any later version. You may not use this file except in + compliance with the License. You may obtain a copy of the + License at: + + https://www.gnu.org/licenses/agpl-3.0.txt + + 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. + +--%> +<%@page language="java" contentType="text/html" session="true" %> + +<%@ page import="org.opennms.web.utils.Bootstrap" %> +<% + String remoteUser = request.getRemoteUser(); + // Escape for safe interpolation into JavaScript string literal + String jsRemoteUser = remoteUser.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("<", "\\x3c") + .replace(">", "\\x3e"); +%> +<% Bootstrap.with(pageContext) + .headTitle("API Tokens") + .breadcrumb("Self-Service", "account/selfService/index.jsp") + .breadcrumb("API Tokens") + .build(request); +%> + + +
+
+
+
+ API Tokens +
+
+ + + + + + + + + + + + + + + + +
DescriptionCreatedExpiresLast Used
+ + +
+
Generate New Token
+
+
+ + +
+
+ + +
+ + +
+
+
+
+ +
+
+
About API Tokens
+
+

API tokens allow programmatic access to the OpenNMS REST API without using your password.

+

Use tokens with the Authorization: Bearer <token> HTTP header.

+

Tokens inherit your current roles and permissions.

+
+
+
+
+ + + + diff --git a/opennms-webapp/src/main/webapp/account/selfService/index.jsp b/opennms-webapp/src/main/webapp/account/selfService/index.jsp index 56c772626161..5a86ccecee49 100644 --- a/opennms-webapp/src/main/webapp/account/selfService/index.jsp +++ b/opennms-webapp/src/main/webapp/account/selfService/index.jsp @@ -79,6 +79,7 @@ @@ -91,9 +92,9 @@

- Currently, account self-service is limited to password changes. Note that in environments using a - reduced sign-on system such as LDAP, changing your password here may have no effect and may not even be - possible. + Account self-service options include password changes and API token management. Note that in environments + using a reduced sign-on system such as LDAP, changing your password here may have no effect and may not + even be possible.

If you require further changes to your account, please contact the person within your organization responsible for diff --git a/opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp b/opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp new file mode 100644 index 000000000000..aba63eeda3f9 --- /dev/null +++ b/opennms-webapp/src/main/webapp/admin/userGroupView/users/apiTokens.jsp @@ -0,0 +1,204 @@ +<%-- + + Licensed to The OpenNMS Group, Inc (TOG) under one or more + contributor license agreements. See the LICENSE.md file + distributed with this work for additional information + regarding copyright ownership. + + TOG licenses this file to You under the GNU Affero General + Public License Version 3 (the "License") or (at your option) + any later version. You may not use this file except in + compliance with the License. You may obtain a copy of the + License at: + + https://www.gnu.org/licenses/agpl-3.0.txt + + 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. + +--%> +<%@page language="java" contentType="text/html" session="true" + import="org.opennms.core.utils.WebSecurityUtils, + org.opennms.web.utils.Bootstrap" +%> + +<% + String userID = request.getParameter("userID"); + if (userID == null || userID.trim().isEmpty()) { + throw new ServletException("userID parameter required"); + } + String htmlUserID = WebSecurityUtils.sanitizeString(userID); + // For JavaScript context: escape backslash, quotes, and control chars + String jsUserID = userID.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("<", "\\x3c") + .replace(">", "\\x3e"); +%> + +<% Bootstrap.with(pageContext) + .headTitle("API Tokens for " + htmlUserID) + .breadcrumb("Admin", "admin/index.jsp") + .breadcrumb("Users", "admin/userGroupView/users/list.jsp") + .breadcrumb("User Detail", "admin/userGroupView/users/userDetail.jsp?userID=" + java.net.URLEncoder.encode(userID, "UTF-8")) + .breadcrumb("API Tokens") + .build(request); +%> + + +

+
+
+
+ API Tokens for user: <%= htmlUserID %> +
+
+ + + + + + + + + + + + + + + + +
DescriptionCreatedExpiresLast Used
+ + +
+
Generate New Token
+
+
+ + +
+
+ + +
+ + +
+
+
+
+
+ + + + diff --git a/opennms-webapp/src/main/webapp/admin/userGroupView/users/userDetail.jsp b/opennms-webapp/src/main/webapp/admin/userGroupView/users/userDetail.jsp index 5360c60ac703..06d139cb1bb9 100644 --- a/opennms-webapp/src/main/webapp/admin/userGroupView/users/userDetail.jsp +++ b/opennms-webapp/src/main/webapp/admin/userGroupView/users/userDetail.jsp @@ -95,6 +95,14 @@ <%=WebSecurityUtils.sanitizeString(user.getUserComments().orElse(""))%> + + + API Tokens: + + + Manage Tokens + +
diff --git a/smoke-test/src/test/java/org/opennms/smoketest/ApiTokenIT.java b/smoke-test/src/test/java/org/opennms/smoketest/ApiTokenIT.java new file mode 100644 index 000000000000..3f45b920e97f --- /dev/null +++ b/smoke-test/src/test/java/org/opennms/smoketest/ApiTokenIT.java @@ -0,0 +1,306 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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.opennms.smoketest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.opennms.smoketest.stacks.OpenNMSStack; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Smoke tests for the API token authentication feature. + * + * Covers: token lifecycle, bearer auth, admin cross-user ops, + * revocation, validation, and information leakage prevention. + */ +public class ApiTokenIT { + + @ClassRule + public static OpenNMSStack stack = OpenNMSStack.MINIMAL; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ADMIN_BASIC = + "Basic " + Base64.getEncoder().encodeToString("admin:admin".getBytes()); + + @After + public void cleanup() { + // Best-effort revoke all tokens created during tests + admin().path("apiTokens").queryParam("username", "admin").request() + .header("Authorization", ADMIN_BASIC).delete(); + admin().path("apiTokens").queryParam("username", "rtc").request() + .header("Authorization", ADMIN_BASIC).delete(); + } + + // === Authentication === + + @Test + public void testBasicAuthStillWorks() { + assertEquals(200, admin().path("apiTokens").request() + .header("Authorization", ADMIN_BASIC).get().getStatus()); + } + + @Test + public void testValidBearerTokenAuthenticates() throws Exception { + String token = createToken("admin", "smoke-test bearer", 30); + assertEquals(200, bearer(token).path("apiTokens").request().get().getStatus()); + } + + @Test + public void testInvalidBearerTokenRejected() { + String fakeToken = "onms_" + "0".repeat(64); + assertEquals(401, bearer(fakeToken).path("apiTokens").request().get().getStatus()); + } + + @Test + public void testMalformedBearerTokenRejected() { + assertEquals(401, bearer("notanopennmstoken").path("apiTokens").request().get().getStatus()); + } + + @Test + public void testNoAuthHeaderRejected() { + assertEquals(401, admin().path("apiTokens").request().get().getStatus()); + } + + // === Token format and create response === + + @Test + public void testCreateTokenReturns201WithExpectedFields() throws Exception { + Response response = admin().path("apiTokens").request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json("{\"description\":\"format test\",\"expiresInDays\":30}")); + assertEquals(201, response.getStatus()); + + Map body = MAPPER.readValue(response.readEntity(String.class), + new TypeReference>() {}); + assertNotNull("id must be present", body.get("id")); + assertNotNull("token must be present", body.get("token")); + assertNotNull("createdAt must be present", body.get("createdAt")); + assertNotNull("expiresAt must be present", body.get("expiresAt")); + assertNull("tokenHash must not be returned", body.get("tokenHash")); + } + + @Test + public void testTokenHasCorrectPrefixAndLength() throws Exception { + String token = createToken("admin", "prefix test", 30); + assertTrue("token must start with onms_", token.startsWith("onms_")); + assertEquals("token must be 69 chars (onms_ + 64 hex)", 69, token.length()); + } + + // === Token lifecycle === + + @Test + public void testLastUsedAtUpdatedAfterUse() throws Exception { + String token = createToken("admin", "lastused test", 30); + int id = tokenIdForToken(token); + + // Use the token once + bearer(token).path("apiTokens").request().get().close(); + + // Fetch token list and check lastUsedAt + List> tokens = listTokens("admin"); + Map found = tokens.stream() + .filter(t -> Integer.valueOf(id).equals(t.get("id"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Token not found in list")); + assertNotNull("lastUsedAt must be non-null after use", found.get("lastUsedAt")); + } + + @Test + public void testTokenHashNotInListResponse() throws Exception { + createToken("admin", "hash leak test", 30); + List> tokens = listTokens("admin"); + for (Map t : tokens) { + assertFalse("tokenHash must not appear in list response", t.containsKey("tokenHash")); + } + } + + @Test + public void testRevokeOwnToken() throws Exception { + String token = createToken("admin", "revoke test", 30); + int id = tokenIdForToken(token); + + Response revoke = admin().path("apiTokens").path(String.valueOf(id)).request() + .header("Authorization", ADMIN_BASIC).delete(); + assertEquals(204, revoke.getStatus()); + + // Token should now be rejected + assertEquals(401, bearer(token).path("apiTokens").request().get().getStatus()); + } + + @Test + public void testRevokeNonExistentTokenReturns404() { + Response response = admin().path("apiTokens").path("99999").request() + .header("Authorization", ADMIN_BASIC).delete(); + assertEquals(404, response.getStatus()); + } + + // === Validation === + + @Test + public void testExpiryExceedingMaxReturns400() { + Response response = admin().path("apiTokens").request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json("{\"description\":\"too long\",\"expiresInDays\":9999}")); + assertEquals(400, response.getStatus()); + // Error body must not leak the configured max value + String body = response.readEntity(String.class); + assertFalse("error body must not leak max-expiry config value", body.contains("365")); + } + + @Test + public void testNegativeExpiryReturns400() { + Response response = admin().path("apiTokens").request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json("{\"description\":\"negative\",\"expiresInDays\":-1}")); + assertEquals(400, response.getStatus()); + } + + // === Admin cross-user operations === + + @Test + public void testAdminCreatesTokenForOtherUser() { + Response response = admin().path("apiTokens").queryParam("username", "rtc").request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json("{\"description\":\"admin-created\",\"expiresInDays\":15}")); + assertEquals(201, response.getStatus()); + } + + @Test + public void testAdminListsOtherUserTokens() throws Exception { + // Create a token for rtc as admin, then verify admin can list it + admin().path("apiTokens").queryParam("username", "rtc").request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json("{\"description\":\"rtc list test\",\"expiresInDays\":15}")); + + Response list = admin().path("apiTokens").queryParam("username", "rtc").request() + .header("Authorization", ADMIN_BASIC).get(); + assertEquals(200, list.getStatus()); + List> tokens = MAPPER.readValue(list.readEntity(String.class), + new TypeReference>>() {}); + assertFalse("admin should see rtc's tokens", tokens.isEmpty()); + } + + @Test + public void testAdminRevokesOtherUserToken() throws Exception { + Response create = admin().path("apiTokens").queryParam("username", "rtc").request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json("{\"description\":\"rtc revoke test\",\"expiresInDays\":15}")); + int id = MAPPER.readValue(create.readEntity(String.class), + new TypeReference>() {}).entrySet().stream() + .filter(e -> "id".equals(e.getKey())) + .mapToInt(e -> (Integer) e.getValue()) + .findFirst().orElseThrow(); + + Response revoke = admin().path("apiTokens").path(String.valueOf(id)).request() + .header("Authorization", ADMIN_BASIC).delete(); + assertEquals(204, revoke.getStatus()); + } + + // === Bulk revoke === + + @Test + public void testRevokeAllTokensForUser() throws Exception { + createToken("admin", "bulk-1", 30); + createToken("admin", "bulk-2", 30); + + Response revoke = admin().path("apiTokens").queryParam("username", "admin").request() + .header("Authorization", ADMIN_BASIC).delete(); + assertEquals(204, revoke.getStatus()); + + assertTrue("token list must be empty after revoke-all", listTokens("admin").isEmpty()); + } + + // === Helpers === + + /** Base WebTarget for /api/v2, no auth header attached. */ + private WebTarget admin() { + Client client = ClientBuilder.newClient(); + return client.target(stack.opennms().getWebUrl().toString() + "opennms/api/v2"); + } + + /** Base WebTarget for /api/v2 with a Bearer authorization header pre-configured. */ + private WebTarget bearer(String token) { + Client client = ClientBuilder.newClient(); + // Register a filter that sets the Bearer header on every request + client.register((javax.ws.rs.client.ClientRequestFilter) ctx -> + ctx.getHeaders().putSingle("Authorization", "Bearer " + token)); + return client.target(stack.opennms().getWebUrl().toString() + "opennms/api/v2"); + } + + /** Create a token for the given user (as admin) and return the plaintext token string. */ + private String createToken(String username, String description, int expiresInDays) throws Exception { + WebTarget target = admin().path("apiTokens"); + if (!"admin".equals(username)) { + target = target.queryParam("username", username); + } + Response response = target.request() + .header("Authorization", ADMIN_BASIC) + .post(Entity.json(String.format( + "{\"description\":\"%s\",\"expiresInDays\":%d}", description, expiresInDays))); + assertEquals(201, response.getStatus()); + Map body = MAPPER.readValue(response.readEntity(String.class), + new TypeReference>() {}); + return (String) body.get("token"); + } + + /** Return the token ID by authenticating with the token and matching it in the list. */ + private int tokenIdForToken(String token) throws Exception { + // Use basic auth to list — bearer auth on the list endpoint also works but + // this avoids a timing dependency on lastUsedAt being null. + List> tokens = listTokens("admin"); + // We can't match by plaintext (it's never stored), so return the latest ID. + // Since tests are sequential within a single class run, the highest ID is the one just created. + return tokens.stream() + .mapToInt(t -> (Integer) t.get("id")) + .max() + .orElseThrow(() -> new AssertionError("No tokens found for admin")); + } + + /** List tokens for a user as admin and return parsed JSON. */ + private List> listTokens(String username) throws Exception { + Response response = admin().path("apiTokens").queryParam("username", username).request() + .header("Authorization", ADMIN_BASIC).get(); + assertEquals(200, response.getStatus()); + return MAPPER.readValue(response.readEntity(String.class), + new TypeReference>>() {}); + } +} diff --git a/ui/src/components/Menu/UserSelfServiceMenuItem.vue b/ui/src/components/Menu/UserSelfServiceMenuItem.vue index 52bce93190ee..4c749fb1831d 100644 --- a/ui/src/components/Menu/UserSelfServiceMenuItem.vue +++ b/ui/src/components/Menu/UserSelfServiceMenuItem.vue @@ -52,6 +52,7 @@ import IconAccountCircle from '@featherds/icon/action/AccountCircle' import IconHelp from '@featherds/icon/action/Help' import IconLogout from '@featherds/icon/action/LogOut' import IconSecurity from '@featherds/icon/network/Security' +import IconApiEndpoints from '@featherds/icon/network/ApiEndpoints' import { ellipsify } from '@/lib/utils' import { performLogout } from '@/services/logoutService' import { useMenuStore } from '@/stores/menuStore' @@ -87,9 +88,10 @@ const showMenu = () => { const menuItems = computed(() => { const helpMenu = mainMenu.value.helpMenu?.items?.find(m => m.id === 'helpMain') const changePasswordMenu = mainMenu.value.selfServiceMenu?.items?.find(m => m.id === 'changePassword') + const apiTokensMenu = mainMenu.value.selfServiceMenu?.items?.find(m => m.id === 'apiTokens') const logoutMenu = mainMenu.value.selfServiceMenu?.items?.find(m => m.id === 'logout') - return [helpMenu, changePasswordMenu, logoutMenu].map(m => m as MenuItem).filter(m => m !== undefined) || [] + return [helpMenu, changePasswordMenu, apiTokensMenu, logoutMenu].map(m => m as MenuItem).filter(m => m !== undefined) || [] }) const createIcon = (menuItem: MenuItem) => { @@ -102,6 +104,8 @@ const createIcon = (menuItem: MenuItem) => { icon = IconLogout; break case 'changePassword': icon = IconSecurity; break + case 'apiTokens': + icon = IconApiEndpoints; break } return (icon ?? IconHelp) as typeof FeatherIcon