mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
323 lines
10 KiB
Go
323 lines
10 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/avct/uasurfer"
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
"github.com/hanzoteam/server/server/v8/channels/utils"
|
|
"github.com/hanzoteam/server/server/v8/platform/services/cache"
|
|
)
|
|
|
|
func (a *App) sessionAttributesEnabled() bool {
|
|
if !a.Config().FeatureFlags.SessionAttributes {
|
|
return false
|
|
}
|
|
return model.MinimumEnterpriseAdvancedLicense(a.License())
|
|
}
|
|
|
|
func (a *App) getSessionAttributeFieldsByName() (map[string]*model.PropertyField, *model.AppError) {
|
|
group, err := a.Srv().propertyService.Group(model.SessionAttributesPropertyGroupName)
|
|
if err != nil {
|
|
return nil, model.NewAppError("getSessionAttributeFieldsByName", "app.property_group.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
fields, err := a.Srv().Store().PropertyField().GetForGroup(context.Background(), group.ID)
|
|
if err != nil {
|
|
return nil, model.NewAppError("getSessionAttributeFieldsByName", "app.property_field.get_for_group.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
fieldsByName := make(map[string]*model.PropertyField, len(fields))
|
|
for _, field := range fields {
|
|
fieldsByName[field.Name] = field
|
|
}
|
|
return fieldsByName, nil
|
|
}
|
|
|
|
func validatedSessionAttributeField(
|
|
fieldsByName map[string]*model.PropertyField,
|
|
name, platform string,
|
|
value any,
|
|
) bool {
|
|
field, ok := fieldsByName[name]
|
|
if !ok {
|
|
return false
|
|
}
|
|
saField, err := model.SAFieldFromPropertyField(field)
|
|
if err != nil || !saField.EnabledForPlatform(platform) {
|
|
return false
|
|
}
|
|
return model.IsValidSessionAttributeValue(field, value)
|
|
}
|
|
|
|
func inferSessionAttributePlatform(r *http.Request) string {
|
|
uaStr := r.UserAgent()
|
|
ua := uasurfer.Parse(uaStr)
|
|
browserName := getBrowserName(ua, uaStr)
|
|
switch browserName {
|
|
case "Desktop App":
|
|
return model.SessionAttributePlatformDesktop
|
|
case "Mobile App":
|
|
return model.SessionAttributePlatformMobile
|
|
default:
|
|
return model.SessionAttributePlatformBrowser
|
|
}
|
|
}
|
|
|
|
func (a *App) ProcessSessionAttributesRequest(rctx request.CTX, r *http.Request) {
|
|
if !a.sessionAttributesEnabled() {
|
|
return
|
|
}
|
|
|
|
// Only process session attributes for actual end-user sessions. Local
|
|
// sessions and OAuth app sessions do not correspond to a physical device
|
|
// with a real security posture, so they must not be able to self-attest
|
|
// session attributes.
|
|
if rctx.Session().Local || rctx.Session().IsOAuth {
|
|
return
|
|
}
|
|
switch rctx.Session().Props[model.SessionPropType] {
|
|
case model.SessionTypeUserAccessToken, model.SessionTypeCloudKey, model.SessionTypeRemoteclusterToken:
|
|
return
|
|
}
|
|
|
|
if err := rctx.Session().IsValid(); err != nil || rctx.Session().IsExpired() {
|
|
return
|
|
}
|
|
|
|
fieldsByName, appErr := a.getSessionAttributeFieldsByName()
|
|
if appErr != nil {
|
|
rctx.Logger().Warn("Failed to load session attribute schema", mlog.Err(appErr))
|
|
return
|
|
}
|
|
|
|
platform := inferSessionAttributePlatform(r)
|
|
uaStr := r.UserAgent()
|
|
ua := uasurfer.Parse(uaStr)
|
|
attrsToMerge := make(map[string]any)
|
|
|
|
for name := range model.SessionAttributesRequestDerivedFieldNames {
|
|
var v string
|
|
switch name {
|
|
case model.SessionAttributesPropertyFieldUserAgentPlatform:
|
|
v = getPlatformName(ua, uaStr)
|
|
case model.SessionAttributesPropertyFieldUserAgentOS:
|
|
v = getOSName(ua, uaStr)
|
|
case model.SessionAttributesPropertyFieldUserAgentBrowserName:
|
|
v = getBrowserName(ua, uaStr)
|
|
case model.SessionAttributesPropertyFieldUserAgentBrowserVersion:
|
|
v = getBrowserVersion(ua, uaStr)
|
|
case model.SessionAttributesPropertyFieldIPAddress:
|
|
v = utils.GetIPAddress(r, a.Config().ServiceSettings.TrustedProxyIPHeader)
|
|
}
|
|
if v == "" || !validatedSessionAttributeField(fieldsByName, name, platform, v) {
|
|
continue
|
|
}
|
|
attrsToMerge[name] = v
|
|
}
|
|
|
|
if headerValue := r.Header.Get(model.SessionAttributeHeaderClientAttributes); headerValue != "" {
|
|
decoded, decodeErr := base64.StdEncoding.DecodeString(headerValue)
|
|
if decodeErr != nil {
|
|
rctx.Logger().Warn("Failed to parse client session attributes header", mlog.Err(fmt.Errorf("decode session attributes header: %w", decodeErr)))
|
|
} else {
|
|
var clientAttrs map[string]any
|
|
if unmarshalErr := json.Unmarshal(decoded, &clientAttrs); unmarshalErr != nil {
|
|
rctx.Logger().Warn("Failed to parse client session attributes header", mlog.Err(fmt.Errorf("unmarshal session attributes header: %w", unmarshalErr)))
|
|
} else {
|
|
if clientAttrs == nil {
|
|
clientAttrs = map[string]any{}
|
|
}
|
|
for name, value := range clientAttrs {
|
|
if name == model.SessionAttributesPropertyFieldTLSDDeviceID {
|
|
continue
|
|
}
|
|
if _, isServerDerived := model.SessionAttributesRequestDerivedFieldNames[name]; isServerDerived {
|
|
continue
|
|
}
|
|
if !validatedSessionAttributeField(fieldsByName, name, platform, value) {
|
|
continue
|
|
}
|
|
attrsToMerge[name] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if *a.Config().AccessControlSettings.TrustProxyDeviceIdentityHeader {
|
|
if proxyDeviceID := strings.TrimSpace(r.Header.Get(model.SessionAttributeHeaderProxyDeviceID)); proxyDeviceID != "" {
|
|
if validatedSessionAttributeField(fieldsByName, model.SessionAttributesPropertyFieldTLSDDeviceID, platform, proxyDeviceID) {
|
|
attrsToMerge[model.SessionAttributesPropertyFieldTLSDDeviceID] = proxyDeviceID
|
|
}
|
|
}
|
|
}
|
|
|
|
if *a.Config().AccessControlSettings.EnforceDeviceIDConsistency {
|
|
cached, _, err := a.Srv().Store().SessionAttribute().Get(rctx.Session().Id)
|
|
if err != nil && !errors.Is(err, cache.ErrKeyNotFound) {
|
|
rctx.Logger().Warn("Failed to load cached session attributes for device ID consistency check", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
for name := range model.SessionAttributesDeviceIDFieldNames {
|
|
var incomingValue, cachedValue string
|
|
if v := attrsToMerge[name]; v != nil {
|
|
if s, ok := v.(string); ok {
|
|
incomingValue = s
|
|
}
|
|
}
|
|
if cached != nil {
|
|
if v := cached[name]; v != nil {
|
|
if s, ok := v.(string); ok {
|
|
cachedValue = s
|
|
}
|
|
}
|
|
}
|
|
|
|
if incomingValue == "" || cachedValue == "" || incomingValue == cachedValue {
|
|
continue
|
|
}
|
|
|
|
if revokeErr := a.RevokeSessionById(rctx, rctx.Session().Id); revokeErr != nil {
|
|
rctx.Logger().Warn("Failed to revoke session after device ID mismatch", mlog.Err(revokeErr))
|
|
} else {
|
|
auditRec := a.MakeAuditRecord(rctx, model.AuditEventRevokeSession, model.AuditStatusSuccess)
|
|
model.AddEventParameterAuditableToAuditRec(auditRec, "session", rctx.Session())
|
|
defer a.LogAuditRec(rctx, auditRec, nil)
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(attrsToMerge) > 0 {
|
|
refreshAt := model.GetMillis()
|
|
if err := a.Srv().Store().SessionAttribute().Refresh(rctx.Session().Id, attrsToMerge, refreshAt); err != nil {
|
|
rctx.Logger().Warn("Failed to refresh session attributes", mlog.Err(err))
|
|
} else {
|
|
a.broadcastSessionAttributes(rctx, rctx.Session().Id, attrsToMerge, refreshAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) broadcastSessionAttributes(rctx request.CTX, sessionID string, attrs map[string]any, refreshAt int64) {
|
|
cluster := a.Cluster()
|
|
if cluster == nil {
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(model.SessionAttributesClusterPayload{
|
|
SessionID: sessionID,
|
|
Attrs: attrs,
|
|
Timestamp: refreshAt,
|
|
})
|
|
if err != nil {
|
|
rctx.Logger().Warn("Failed to encode session attributes for cluster broadcast", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
cluster.SendClusterMessage(&model.ClusterMessage{
|
|
Event: model.ClusterEventUpdateSessionAttributes,
|
|
SendType: model.ClusterSendBestEffort,
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
func (a *App) GetSessionAttributesManifest(rctx request.CTX, r *http.Request) ([]*model.SessionAttributeManifestEntry, *model.AppError) {
|
|
if !a.sessionAttributesEnabled() {
|
|
return nil, model.NewAppError("GetSessionAttributesManifest", "api.user.session_attributes.disabled.app_error", nil, "", http.StatusNotImplemented)
|
|
}
|
|
|
|
fieldsByName, appErr := a.getSessionAttributeFieldsByName()
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
platform := inferSessionAttributePlatform(r)
|
|
manifest := make([]*model.SessionAttributeManifestEntry, 0, len(fieldsByName))
|
|
for _, field := range fieldsByName {
|
|
saField, err := model.SAFieldFromPropertyField(field)
|
|
if err != nil || !saField.EnabledForPlatform(platform) {
|
|
continue
|
|
}
|
|
if _, isDerived := model.SessionAttributesRequestDerivedFieldNames[field.Name]; isDerived {
|
|
continue
|
|
}
|
|
manifest = append(manifest, &model.SessionAttributeManifestEntry{
|
|
Name: field.Name,
|
|
Type: string(field.Type),
|
|
TTLSeconds: saField.Attrs.TTLSeconds,
|
|
GracePeriodSeconds: saField.Attrs.GracePeriodSeconds,
|
|
Platforms: saField.Attrs.Platforms,
|
|
DisplayName: saField.Attrs.DisplayName,
|
|
})
|
|
}
|
|
|
|
return manifest, nil
|
|
}
|
|
|
|
func (a *App) GetSessionAttributes(sessionID string) (map[string]any, *model.AppError) {
|
|
if !a.sessionAttributesEnabled() {
|
|
return nil, nil
|
|
}
|
|
|
|
attrs, timestamps, err := a.Srv().Store().SessionAttribute().Get(sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, cache.ErrKeyNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, model.NewAppError("GetSessionAttributes", "app.access_control.get_session_attributes.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
fieldsByName, appErr := a.getSessionAttributeFieldsByName()
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return filterStaleSessionAttributes(attrs, timestamps, fieldsByName), nil
|
|
}
|
|
|
|
func filterStaleSessionAttributes(attrs map[string]any, timestamps map[string]int64, fieldsByName map[string]*model.PropertyField) map[string]any {
|
|
if len(attrs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
filtered := make(map[string]any, len(attrs))
|
|
for name, value := range attrs {
|
|
field, ok := fieldsByName[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
saField, err := model.SAFieldFromPropertyField(field)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
timestamp, ok := timestamps[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
expiry := time.Duration(saField.Attrs.TTLSeconds+saField.Attrs.GracePeriodSeconds) * time.Second
|
|
if now.After(time.UnixMilli(timestamp).Add(expiry)) {
|
|
continue
|
|
}
|
|
filtered[name] = value
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
return filtered
|
|
}
|