Push Proxy Authentication (#34211)

* Initial Implementation of Push Proxy Authentication

* Include Config Listener for Leader plus delete startup function as job scheduler runs on initialization

* Remove push proxy auth from local imports

* Add push proxy auth to external imports

* Add push proxy auth error messages

* Update error codes

* Fix enterprise dep definition

* make i18n-extract

* Mock System store Get

* m

* m

* m

* m

* Update serverID header

* Add install type env var to docker

* Update Push Proxy config with new options

Global, US, Germany and Japan. Previous configurations will keep working

* use model.SafeDereference

* Delete token when new push proxy URL is empty

* ServerID header only if auth token is available

---------

Co-authored-by: Daniel Schalla <[email protected]>
Co-authored-by: Nick Misasi <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>
This commit is contained in:
Eva Sarafianou
2025-11-12 20:16:44 +02:00
committed by GitHub
co-authored by Daniel Schalla Nick Misasi Mattermost Build
parent 8b09ce4f29
commit 86caa528f6
21 changed files with 223 additions and 14 deletions
@@ -352,7 +352,7 @@ const defaultServerConfig: AdminConfig = {
SendPushNotifications: true,
PushNotificationServer: 'https://push-test.mattermost.com',
PushNotificationServerType: 'custom',
PushNotificationServerLocation: 'us',
PushNotificationServerLocation: 'global',
PushNotificationContents: 'full',
PushNotificationBuffer: 1000,
EnableEmailBatching: false,
+1
View File
@@ -41,6 +41,7 @@ FROM gcr.io/distroless/base-debian12
# Some ENV variables
ENV PATH="/mattermost/bin:${PATH}"
ENV MM_SERVICESETTINGS_ENABLELOCALMODE="true"
ENV MM_INSTALL_TYPE="docker"
# Copy over metadata files needed by runtime
COPY --from=builder /etc/mime.types /etc
+1
View File
@@ -42,6 +42,7 @@ FROM cgr.dev/mattermost.com/glibc-openssl-fips:15@sha256:7947eecc0d82fa3bc661aac
# Some ENV variables
ENV PATH="/mattermost/bin:${PATH}"
ENV MM_SERVICESETTINGS_ENABLELOCALMODE="true"
ENV MM_INSTALL_TYPE="docker"
# Copy over metadata files needed by runtime
COPY --from=builder /etc/mime.types /etc
+37
View File
@@ -134,6 +134,43 @@ func NewChannels(s *Server) (*Channels, error) {
}
})
}
if pushProxyInterface != nil {
app := New(ServerConnector(ch))
s.PushProxy = pushProxyInterface(app)
// Add config listener to regenerate token when push proxy URL changes
app.AddConfigListener(func(oldCfg, newCfg *model.Config) {
// Only cluster leader should regenerate to avoid duplicate requests
if !app.IsLeader() {
return
}
oldURL := model.SafeDereference(oldCfg.EmailSettings.PushNotificationServer)
newURL := model.SafeDereference(newCfg.EmailSettings.PushNotificationServer)
// If push proxy URL changed
if oldURL != newURL {
if newURL != "" {
// URL changed to a new value, regenerate token
s.Log().Info("Push notification server URL changed, regenerating auth token",
mlog.String("old_url", oldURL),
mlog.String("new_url", newURL))
if err := s.PushProxy.GenerateAuthToken(); err != nil {
s.Log().Error("Failed to regenerate auth token after config change", mlog.Err(err))
}
} else if oldURL != "" {
// URL was cleared, delete the old token
s.Log().Info("Push notification server URL cleared, removing auth token")
if err := s.PushProxy.DeleteAuthToken(); err != nil {
s.Log().Error("Failed to delete auth token after URL cleared", mlog.Err(err))
}
}
}
})
}
if accessControlServiceInterface != nil {
app := New(ServerConnector(ch))
ch.AccessControl = accessControlServiceInterface(app)
+6
View File
@@ -110,6 +110,12 @@ func RegisterJobsAccessControlSyncJobInterface(f func(*Server) ejobs.AccessContr
jobsAccessControlSyncJobInterface = f
}
var pushProxyInterface func(*App) einterfaces.PushProxyInterface
func RegisterPushProxyInterface(f func(*App) einterfaces.PushProxyInterface) {
pushProxyInterface = f
}
func (s *Server) initEnterprise() {
if cloudInterface != nil {
s.Cloud = cloudInterface(s)
+9 -1
View File
@@ -31,7 +31,15 @@ func (a *App) canSendPushNotifications() bool {
}
pushServer := *a.Config().EmailSettings.PushNotificationServer
if license := a.Srv().License(); pushServer == model.MHPNS && (license == nil || !*license.Features.MHPNS) {
// Check for MHPNS servers (both current and legacy DNS aliases)
isMHPNSServer := pushServer == model.MHPNS ||
pushServer == model.MHPNSLegacyUS ||
pushServer == model.MHPNSLegacyDE ||
pushServer == model.MHPNSGlobal ||
pushServer == model.MHPNSUS ||
pushServer == model.MHPNSEU ||
pushServer == model.MHPNSAP
if license := a.Srv().License(); isMHPNSServer && (license == nil || !*license.Features.MHPNS) {
a.Log().LogM(mlog.MlvlNotificationWarn, "Push notifications are disabled - license missing",
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", "push_disabled_license"),
+16
View File
@@ -494,6 +494,14 @@ func (a *App) rawSendToPushProxy(msg *model.PushNotification) (model.PushRespons
return nil, err
}
// Add auth token and server ID headers if available
if a.Srv().PushProxy != nil {
if authToken := a.Srv().PushProxy.GetAuthToken(); authToken != "" {
request.Header.Set("X-Mattermost-Auth", authToken)
request.Header.Set("X-Mattermost-ServerID", a.ServerId())
}
}
resp, err := a.Srv().pushNotificationClient.Do(request)
if err != nil {
return nil, err
@@ -563,6 +571,14 @@ func (a *App) SendAckToPushProxy(rctx request.CTX, ack *model.PushNotificationAc
return fmt.Errorf("failed to create request: %w", err)
}
// Add auth token and server ID headers if available
if a.Srv().PushProxy != nil {
if authToken := a.Srv().PushProxy.GetAuthToken(); authToken != "" {
request.Header.Set("X-Mattermost-Auth", authToken)
request.Header.Set("X-Mattermost-ServerID", a.ServerId())
}
}
resp, err := a.Srv().pushNotificationClient.Do(request)
if err != nil {
return fmt.Errorf("failed to send: %w", err)
@@ -1422,6 +1422,7 @@ func TestSendAckToPushProxy(t *testing.T) {
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: model.NewId()}, nil)
mockStore.On("User").Return(&mockUserStore)
mockStore.On("Post").Return(&mockPostStore)
+6
View File
@@ -144,6 +144,7 @@ type Server struct {
Cloud einterfaces.CloudInterface
IPFiltering einterfaces.IPFilteringInterface
OutgoingOAuthConnection einterfaces.OutgoingOAuthConnectionInterface
PushProxy einterfaces.PushProxyInterface
ch *Channels
}
@@ -1453,6 +1454,11 @@ func (s *Server) initJobs() {
s.Jobs.RegisterJobType(model.JobTypeAccessControlSync, builder.MakeWorker(), builder.MakeScheduler())
}
if pushProxyInterface != nil {
builder := pushProxyInterface(New(ServerConnector(s.Channels())))
s.Jobs.RegisterJobType(model.JobTypePushProxyAuth, builder.MakeWorker(), builder.MakeScheduler())
}
s.Jobs.RegisterJobType(
model.JobTypeMigrations,
migrations.MakeWorker(s.Jobs, s.Store()),
+27
View File
@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package einterfaces
import (
"github.com/mattermost/mattermost/server/public/model"
ejobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
)
type PushProxyInterface interface {
// GetAuthToken returns the current auth token
// Returns empty string if not available or enterprise is not enabled
GetAuthToken() string
// GenerateAuthToken generates and stores an authentication token
GenerateAuthToken() *model.AppError
// DeleteAuthToken deletes the stored authentication token
DeleteAuthToken() *model.AppError
// MakeWorker creates a worker for the auth token generation job
MakeWorker() model.Worker
// MakeScheduler creates a scheduler for the auth token generation job
MakeScheduler() ejobs.Scheduler
}
+2
View File
@@ -41,6 +41,8 @@ import (
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export/actiance_export"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/push_proxy"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export/csv_export"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/message_export/global_relay_export"
+48
View File
@@ -8752,6 +8752,54 @@
"id": "ent.outgoing_oauth_connections.update_connection.audience_invalid",
"translation": "The provided audience is invalid: {{ .Error }}"
},
{
"id": "ent.push_proxy.delete.app_error",
"translation": "Failed to delete push proxy auth token."
},
{
"id": "ent.push_proxy.generate.bad_status.app_error",
"translation": "Failed to get auth token from push proxy server."
},
{
"id": "ent.push_proxy.generate.create_request.app_error",
"translation": "Failed to create HTTP request for push proxy auth."
},
{
"id": "ent.push_proxy.generate.empty_token.app_error",
"translation": "Push proxy returned empty auth token."
},
{
"id": "ent.push_proxy.generate.encrypt.app_error",
"translation": "Failed to encrypt payload for push proxy auth."
},
{
"id": "ent.push_proxy.generate.http_request.app_error",
"translation": "Failed to send HTTP request to push proxy."
},
{
"id": "ent.push_proxy.generate.marshal.app_error",
"translation": "Failed to marshal payload for push proxy auth."
},
{
"id": "ent.push_proxy.generate.marshal_request.app_error",
"translation": "Failed to marshal request body for push proxy auth."
},
{
"id": "ent.push_proxy.generate.no_key.app_error",
"translation": "No encryption key available for push proxy auth."
},
{
"id": "ent.push_proxy.generate.parse_response.app_error",
"translation": "Failed to parse response from push proxy auth endpoint."
},
{
"id": "ent.push_proxy.generate.save_system.app_error",
"translation": "Failed to save auth token."
},
{
"id": "ent.push_proxy.worker.interface_nil.app_error",
"translation": "Push proxy interface is nil."
},
{
"id": "ent.saml.attribute.app_error",
"translation": "SAML login was unsuccessful because one of the attributes is incorrect. Please contact your System Administrator."
+1
View File
@@ -44,6 +44,7 @@ const (
JobTypeDeleteDmsPreferencesMigration = "delete_dms_preferences_migration"
JobTypeMobileSessionMetadata = "mobile_session_metadata"
JobTypeAccessControlSync = "access_control_sync"
JobTypePushProxyAuth = "push_proxy_auth"
JobStatusPending = "pending"
JobStatusInProgress = "in_progress"
+10 -1
View File
@@ -26,7 +26,16 @@ const (
// with the push notifications
CategoryCanReply = "CAN_REPLY"
MHPNS = "https://push.mattermost.com"
// Push notification server URLs
// Legacy URLs are DNS aliases that automatically route to the regional endpoints
MHPNSLegacyUS = "https://push.mattermost.com"
MHPNSLegacyDE = "https://hpns-de.mattermost.com"
// Current regional URLs
MHPNSGlobal = "https://global.push.mattermost.com"
MHPNSUS = "https://us.push.mattermost.com"
MHPNSEU = "https://eu.push.mattermost.com"
MHPNSAP = "https://ap.push.mattermost.com"
MHPNS = MHPNSUS // Legacy constant for backwards compatibility
PushSendPrepare = "Prepared to send"
PushSendSuccess = "Successful"
+1
View File
@@ -20,6 +20,7 @@ const (
SystemFirstAdminRole = "FirstAdminRole"
SystemFirstServerRunTimestampKey = "FirstServerRunTimestamp"
SystemClusterEncryptionKey = "ClusterEncryptionKey"
SystemPushProxyAuthToken = "PushProxyAuthToken"
SystemUpgradedFromTeId = "UpgradedFromTE"
SystemWarnMetricNumberOfTeams5 = "warn_metric_number_of_teams_5"
SystemWarnMetricNumberOfChannels50 = "warn_metric_number_of_channels_50"
@@ -50,6 +50,12 @@ exports[`components/PushSettings should match snapshot, licensed 1`] = `
}
/>
<Memo(DropdownSetting)
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Location of the push notification server. Global uses geobalancing to route requests to the nearest server for optimal performance."
id="admin.email.pushServerLocationHelp"
/>
}
id="pushNotificationServerLocation"
label={
<Memo(MemoizedFormattedMessage)
@@ -59,9 +65,13 @@ exports[`components/PushSettings should match snapshot, licensed 1`] = `
}
onChange={[Function]}
setByEnv={false}
value="us"
value="global"
values={
Array [
Object {
"text": "Global",
"value": "global",
},
Object {
"text": "US",
"value": "us",
@@ -70,6 +80,10 @@ exports[`components/PushSettings should match snapshot, licensed 1`] = `
"text": "Germany",
"value": "de",
},
Object {
"text": "Japan",
"value": "jp",
},
]
}
/>
@@ -129,7 +143,7 @@ exports[`components/PushSettings should match snapshot, licensed 1`] = `
}
}
setByEnv={false}
value="https://push.mattermost.com"
value="https://global.push.mattermost.com"
/>
<AdminTextSetting
helpText={
@@ -260,7 +274,7 @@ exports[`components/PushSettings should match snapshot, unlicensed 1`] = `
}
}
setByEnv={false}
value="https://push.mattermost.com"
value="https://global.push.mattermost.com"
/>
<AdminTextSetting
helpText={
@@ -13,7 +13,7 @@ describe('components/PushSettings', () => {
test('should match snapshot, licensed', () => {
const config = {
EmailSettings: {
PushNotificationServer: 'https://push.mattermost.com',
PushNotificationServer: 'https://global.push.mattermost.com',
PushNotificationServerType: 'mhpns',
SendPushNotifications: true,
},
@@ -41,7 +41,7 @@ describe('components/PushSettings', () => {
test('should match snapshot, unlicensed', () => {
const config = {
EmailSettings: {
PushNotificationServer: 'https://push.mattermost.com',
PushNotificationServer: 'https://global.push.mattermost.com',
PushNotificationServerType: 'mhpns',
SendPushNotifications: true,
},
@@ -34,12 +34,16 @@ const PUSH_NOTIFICATIONS_OFF = 'off';
const PUSH_NOTIFICATIONS_MHPNS = 'mhpns';
const PUSH_NOTIFICATIONS_MTPNS = 'mtpns';
const PUSH_NOTIFICATIONS_CUSTOM = 'custom';
const PUSH_NOTIFICATIONS_LOCATION_GLOBAL = 'global';
const PUSH_NOTIFICATIONS_LOCATION_US = 'us';
const PUSH_NOTIFICATIONS_LOCATION_DE = 'de';
const PUSH_NOTIFICATIONS_LOCATION_JP = 'jp';
const PUSH_NOTIFICATIONS_SERVER_DIC = {
[PUSH_NOTIFICATIONS_LOCATION_GLOBAL]: Constants.MHPNS_GLOBAL,
[PUSH_NOTIFICATIONS_LOCATION_US]: Constants.MHPNS_US,
[PUSH_NOTIFICATIONS_LOCATION_DE]: Constants.MHPNS_DE,
[PUSH_NOTIFICATIONS_LOCATION_JP]: Constants.MHPNS_JP,
};
const DROPDOWN_ID_SERVER_TYPE = 'pushNotificationServerType';
@@ -112,19 +116,31 @@ class PushSettings extends OLDAdminSettings<Props, State> {
getStateFromConfig(config: Props['config']) {
let pushNotificationServerType: EmailSettings['PushNotificationServerType'] = PUSH_NOTIFICATIONS_CUSTOM;
let agree = false;
let pushNotificationServerLocation: EmailSettings['PushNotificationServerLocation'] = PUSH_NOTIFICATIONS_LOCATION_US;
let pushNotificationServerLocation: EmailSettings['PushNotificationServerLocation'] = PUSH_NOTIFICATIONS_LOCATION_GLOBAL;
if (!config.EmailSettings.SendPushNotifications) {
pushNotificationServerType = PUSH_NOTIFICATIONS_OFF;
} else if (config.EmailSettings.PushNotificationServer === Constants.MHPNS_US &&
} else if (config.EmailSettings.PushNotificationServer === Constants.MHPNS_GLOBAL &&
this.props.license.IsLicensed === 'true' && this.props.license.MHPNS === 'true') {
pushNotificationServerType = PUSH_NOTIFICATIONS_MHPNS;
pushNotificationServerLocation = PUSH_NOTIFICATIONS_LOCATION_GLOBAL;
agree = true;
} else if ((config.EmailSettings.PushNotificationServer === Constants.MHPNS_US ||
config.EmailSettings.PushNotificationServer === Constants.MHPNS_LEGACY_US) &&
this.props.license.IsLicensed === 'true' && this.props.license.MHPNS === 'true') {
pushNotificationServerType = PUSH_NOTIFICATIONS_MHPNS;
pushNotificationServerLocation = PUSH_NOTIFICATIONS_LOCATION_US;
agree = true;
} else if (config.EmailSettings.PushNotificationServer === Constants.MHPNS_DE &&
} else if ((config.EmailSettings.PushNotificationServer === Constants.MHPNS_DE ||
config.EmailSettings.PushNotificationServer === Constants.MHPNS_LEGACY_DE) &&
this.props.license.IsLicensed === 'true' && this.props.license.MHPNS === 'true') {
pushNotificationServerType = PUSH_NOTIFICATIONS_MHPNS;
pushNotificationServerLocation = PUSH_NOTIFICATIONS_LOCATION_DE;
agree = true;
} else if (config.EmailSettings.PushNotificationServer === Constants.MHPNS_JP &&
this.props.license.IsLicensed === 'true' && this.props.license.MHPNS === 'true') {
pushNotificationServerType = PUSH_NOTIFICATIONS_MHPNS;
pushNotificationServerLocation = PUSH_NOTIFICATIONS_LOCATION_JP;
agree = true;
} else if (config.EmailSettings.PushNotificationServer === Constants.MTPNS) {
pushNotificationServerType = PUSH_NOTIFICATIONS_MTPNS;
}
@@ -313,8 +329,10 @@ class PushSettings extends OLDAdminSettings<Props, State> {
let locationDropdown;
if (this.state.pushNotificationServerType === PUSH_NOTIFICATIONS_MHPNS) {
const pushNotificationServerLocations = [
{value: PUSH_NOTIFICATIONS_LOCATION_GLOBAL, text: this.props.intl.formatMessage({id: 'admin.email.pushServerLocationGlobal', defaultMessage: 'Global'})},
{value: PUSH_NOTIFICATIONS_LOCATION_US, text: this.props.intl.formatMessage({id: 'admin.email.pushServerLocationUS', defaultMessage: 'US'})},
{value: PUSH_NOTIFICATIONS_LOCATION_DE, text: this.props.intl.formatMessage({id: 'admin.email.pushServerLocationDE', defaultMessage: 'Germany'})},
{value: PUSH_NOTIFICATIONS_LOCATION_JP, text: this.props.intl.formatMessage({id: 'admin.email.pushServerLocationJP', defaultMessage: 'Japan'})},
];
locationDropdown = (
@@ -327,6 +345,12 @@ class PushSettings extends OLDAdminSettings<Props, State> {
defaultMessage='Push Notification Server location:'
/>
}
helpText={
<FormattedMessage
id='admin.email.pushServerLocationHelp'
defaultMessage='Location of the push notification server. Global uses geobalancing to route requests to the nearest server for optimal performance.'
/>
}
value={this.state.pushNotificationServerLocation}
onChange={this.handleDropdownChange}
setByEnv={this.isPushNotificationServerSetByEnv()}
+3
View File
@@ -981,6 +981,9 @@
"admin.email.pushOffHelp": "Please see <link>documentation on push notifications</link> to learn more about setup options.",
"admin.email.pushServerEx": "E.g.: \"https://push-test.mattermost.com\"",
"admin.email.pushServerLocationDE": "Germany",
"admin.email.pushServerLocationGlobal": "Global",
"admin.email.pushServerLocationHelp": "Location of the push notification server. Global uses geobalancing to route requests to the nearest server for optimal performance.",
"admin.email.pushServerLocationJP": "Japan",
"admin.email.pushServerLocationTitle": "Push Notification Server location:",
"admin.email.pushServerLocationUS": "US",
"admin.email.pushServerTitle": "Push Notification Server:",
+6 -2
View File
@@ -2065,8 +2065,12 @@ export const Constants = {
EMOJI_PATH: '/static/emoji',
RECENT_EMOJI_KEY: 'recentEmojis',
DEFAULT_WEBHOOK_LOGO: logoWebhook,
MHPNS_US: 'https://push.mattermost.com',
MHPNS_DE: 'https://hpns-de.mattermost.com',
MHPNS_LEGACY_US: 'https://push.mattermost.com',
MHPNS_LEGACY_DE: 'https://hpns-de.mattermost.com',
MHPNS_GLOBAL: 'https://global.push.mattermost.com',
MHPNS_US: 'https://us.push.mattermost.com',
MHPNS_DE: 'https://eu.push.mattermost.com',
MHPNS_JP: 'https://ap.push.mattermost.com',
MTPNS: 'https://push-test.mattermost.com',
MAX_PREV_MSGS: 100,
POST_COLLAPSE_TIMEOUT: 1000 * 60 * 5, // five minutes
+1 -1
View File
@@ -596,7 +596,7 @@ export type EmailSettings = {
SendPushNotifications: boolean;
PushNotificationServer: string;
PushNotificationServerType: 'off' | 'mhpns' | 'mtpns' | 'custom';
PushNotificationServerLocation: 'us' | 'de';
PushNotificationServerLocation: 'global' | 'us' | 'de' | 'jp';
PushNotificationContents: string;
PushNotificationBuffer: number;
EnableEmailBatching: boolean;