Adding dashboard login activity (#1092)

For better auditing this PR adds a dashboard login event to the management service.

For that the user object was extended with a field for last login that is not actively saved to the database but kept in memory until next write. The information about the last login can be extracted from the JWT claims nb_last_login. This timestamp will be stored and compared on each API request. If the value changes we generate an event to inform about a login.
This commit is contained in:
pascal-fischer 2023-08-18 19:23:11 +02:00 committed by GitHub
parent 3ac32fd78a
commit da75a76d41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 110 additions and 13 deletions

View File

@ -189,14 +189,15 @@ type Account struct {
} }
type UserInfo struct { type UserInfo struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
Role string `json:"role"` Role string `json:"role"`
AutoGroups []string `json:"auto_groups"` AutoGroups []string `json:"auto_groups"`
Status string `json:"-"` Status string `json:"-"`
IsServiceUser bool `json:"is_service_user"` IsServiceUser bool `json:"is_service_user"`
IsBlocked bool `json:"is_blocked"` IsBlocked bool `json:"is_blocked"`
LastLogin time.Time `json:"last_login"`
} }
// getRoutesToSync returns the enabled routes for the peer ID and the routes // getRoutesToSync returns the enabled routes for the peer ID and the routes

View File

@ -110,6 +110,8 @@ const (
UserLoggedInPeer UserLoggedInPeer
// PeerLoginExpired indicates that the user peer login has been expired and peer disconnected // PeerLoginExpired indicates that the user peer login has been expired and peer disconnected
PeerLoginExpired PeerLoginExpired
// DashboardLogin indicates that the user logged in to the dashboard
DashboardLogin
) )
var activityMap = map[Activity]Code{ var activityMap = map[Activity]Code{
@ -163,6 +165,7 @@ var activityMap = map[Activity]Code{
GroupDeleted: {"Group deleted", "group.delete"}, GroupDeleted: {"Group deleted", "group.delete"},
UserLoggedInPeer: {"User logged in peer", "user.peer.login"}, UserLoggedInPeer: {"User logged in peer", "user.peer.login"},
PeerLoginExpired: {"Peer login expired", "peer.login.expire"}, PeerLoginExpired: {"Peer login expired", "peer.login.expire"},
DashboardLogin: {"Dashboard login", "dashboard.login"},
} }
// StringCode returns a string code of the activity // StringCode returns a string code of the activity

View File

@ -570,6 +570,26 @@ func (s *FileStore) SavePeerStatus(accountID, peerID string, peerStatus PeerStat
return nil return nil
} }
// SaveUserLastLogin stores the last login time for a user in memory. It doesn't attempt to persist data to speed up things.
func (s *FileStore) SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error {
s.mux.Lock()
defer s.mux.Unlock()
account, err := s.getAccount(accountID)
if err != nil {
return err
}
peer := account.Users[userID]
if peer == nil {
return status.Errorf(status.NotFound, "user %s not found", userID)
}
peer.LastLogin = lastLogin
return nil
}
// Close the FileStore persisting data to disk // Close the FileStore persisting data to disk
func (s *FileStore) Close() error { func (s *FileStore) Close() error {
s.mux.Lock() s.mux.Lock()

View File

@ -100,6 +100,11 @@ components:
type: string type: string
enum: [ "active","invited","blocked" ] enum: [ "active","invited","blocked" ]
example: active example: active
last_login:
description: Last time this user performed a login to the dashboard
type: string
format: date-time
example: 2023-05-05T09:00:35.477782Z
auto_groups: auto_groups:
description: Groups to auto-assign to peers registered by this user description: Groups to auto-assign to peers registered by this user
type: array type: array

View File

@ -767,6 +767,9 @@ type User struct {
// IsServiceUser Is true if this user is a service user // IsServiceUser Is true if this user is a service user
IsServiceUser *bool `json:"is_service_user,omitempty"` IsServiceUser *bool `json:"is_service_user,omitempty"`
// LastLogin Last time this user performed a login to the dashboard
LastLogin *time.Time `json:"last_login,omitempty"`
// Name User's name from idp provider // Name User's name from idp provider
Name string `json:"name"` Name string `json:"name"`

View File

@ -270,5 +270,6 @@ func toUserResponse(user *server.UserInfo, currenUserID string) *api.User {
IsCurrent: &isCurrent, IsCurrent: &isCurrent,
IsServiceUser: &user.IsServiceUser, IsServiceUser: &user.IsServiceUser,
IsBlocked: user.IsBlocked, IsBlocked: user.IsBlocked,
LastLogin: &user.LastLogin,
} }
} }

View File

@ -1,6 +1,8 @@
package jwtclaims package jwtclaims
import ( import (
"time"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
) )
@ -10,6 +12,7 @@ type AuthorizationClaims struct {
AccountId string AccountId string
Domain string Domain string
DomainCategory string DomainCategory string
LastLogin time.Time
Raw jwt.MapClaims Raw jwt.MapClaims
} }

View File

@ -2,6 +2,7 @@ package jwtclaims
import ( import (
"net/http" "net/http"
"time"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
) )
@ -17,6 +18,8 @@ const (
DomainCategorySuffix = "wt_account_domain_category" DomainCategorySuffix = "wt_account_domain_category"
// UserIDClaim claim for the user id // UserIDClaim claim for the user id
UserIDClaim = "sub" UserIDClaim = "sub"
// LastLoginSuffix claim for the last login
LastLoginSuffix = "nb_last_login"
) )
// ExtractClaims Extract function type // ExtractClaims Extract function type
@ -93,9 +96,24 @@ func (c *ClaimsExtractor) FromToken(token *jwt.Token) AuthorizationClaims {
if ok { if ok {
jwtClaims.DomainCategory = domainCategoryClaim.(string) jwtClaims.DomainCategory = domainCategoryClaim.(string)
} }
LastLoginClaimString, ok := claims[c.authAudience+LastLoginSuffix]
if ok {
jwtClaims.LastLogin = parseTime(LastLoginClaimString.(string))
}
return jwtClaims return jwtClaims
} }
func parseTime(timeString string) time.Time {
if timeString == "" {
return time.Time{}
}
parsedTime, err := time.Parse(time.RFC3339, timeString)
if err != nil {
return time.Time{}
}
return parsedTime
}
// fromRequestContext extracts claims from the request context previously filled by the JWT token (after auth) // fromRequestContext extracts claims from the request context previously filled by the JWT token (after auth)
func (c *ClaimsExtractor) fromRequestContext(r *http.Request) AuthorizationClaims { func (c *ClaimsExtractor) fromRequestContext(r *http.Request) AuthorizationClaims {
if r.Context().Value(TokenUserProperty) == nil { if r.Context().Value(TokenUserProperty) == nil {

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"net/http" "net/http"
"testing" "testing"
"time"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func newTestRequestWithJWT(t *testing.T, claims AuthorizationClaims, audiance string) *http.Request { func newTestRequestWithJWT(t *testing.T, claims AuthorizationClaims, audiance string) *http.Request {
const layout = "2006-01-02T15:04:05.999Z"
claimMaps := jwt.MapClaims{} claimMaps := jwt.MapClaims{}
if claims.UserId != "" { if claims.UserId != "" {
claimMaps[UserIDClaim] = claims.UserId claimMaps[UserIDClaim] = claims.UserId
@ -23,6 +26,9 @@ func newTestRequestWithJWT(t *testing.T, claims AuthorizationClaims, audiance st
if claims.DomainCategory != "" { if claims.DomainCategory != "" {
claimMaps[audiance+DomainCategorySuffix] = claims.DomainCategory claimMaps[audiance+DomainCategorySuffix] = claims.DomainCategory
} }
if claims.LastLogin != (time.Time{}) {
claimMaps[audiance+LastLoginSuffix] = claims.LastLogin.Format(layout)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claimMaps) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claimMaps)
r, err := http.NewRequest(http.MethodGet, "http://localhost", nil) r, err := http.NewRequest(http.MethodGet, "http://localhost", nil)
require.NoError(t, err, "creating testing request failed") require.NoError(t, err, "creating testing request failed")
@ -40,6 +46,9 @@ func TestExtractClaimsFromRequestContext(t *testing.T) {
expectedMSG string expectedMSG string
} }
const layout = "2006-01-02T15:04:05.999Z"
lastLogin, _ := time.Parse(layout, "2023-08-17T09:30:40.465Z")
testCase1 := test{ testCase1 := test{
name: "All Claim Fields", name: "All Claim Fields",
inputAudiance: "https://login/", inputAudiance: "https://login/",
@ -47,11 +56,13 @@ func TestExtractClaimsFromRequestContext(t *testing.T) {
UserId: "test", UserId: "test",
Domain: "test.com", Domain: "test.com",
AccountId: "testAcc", AccountId: "testAcc",
LastLogin: lastLogin,
DomainCategory: "public", DomainCategory: "public",
Raw: jwt.MapClaims{ Raw: jwt.MapClaims{
"https://login/wt_account_domain": "test.com", "https://login/wt_account_domain": "test.com",
"https://login/wt_account_domain_category": "public", "https://login/wt_account_domain_category": "public",
"https://login/wt_account_id": "testAcc", "https://login/wt_account_id": "testAcc",
"https://login/nb_last_login": lastLogin.Format(layout),
"sub": "test", "sub": "test",
}, },
}, },

View File

@ -1,5 +1,7 @@
package server package server
import "time"
type Store interface { type Store interface {
GetAllAccounts() []*Account GetAllAccounts() []*Account
GetAccount(accountID string) (*Account, error) GetAccount(accountID string) (*Account, error)
@ -20,6 +22,7 @@ type Store interface {
// AcquireGlobalLock should attempt to acquire a global lock and return a function that releases the lock // AcquireGlobalLock should attempt to acquire a global lock and return a function that releases the lock
AcquireGlobalLock() func() AcquireGlobalLock() func()
SavePeerStatus(accountID, peerID string, status PeerStatus) error SavePeerStatus(accountID, peerID string, status PeerStatus) error
SaveUserLastLogin(accountID, userID string, lastLogin time.Time) error
// Close should close the store persisting all unsaved data. // Close should close the store persisting all unsaved data.
Close() error Close() error
} }

View File

@ -1,9 +1,11 @@
package server package server
import ( import (
"github.com/netbirdio/netbird/management/proto"
log "github.com/sirupsen/logrus"
"sync" "sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/proto"
) )
const channelBufferSize = 100 const channelBufferSize = 100
@ -33,7 +35,7 @@ func (p *PeersUpdateManager) SendUpdate(peerID string, update *UpdateMessage) er
if channel, ok := p.peerChannels[peerID]; ok { if channel, ok := p.peerChannels[peerID]; ok {
select { select {
case channel <- update: case channel <- update:
log.Infof("update was sent to channel for peer %s", peerID) log.Debugf("update was sent to channel for peer %s", peerID)
default: default:
log.Warnf("channel for peer %s is %d full", peerID, len(channel)) log.Warnf("channel for peer %s is %d full", peerID, len(channel))
} }
@ -52,7 +54,7 @@ func (p *PeersUpdateManager) CreateChannel(peerID string) chan *UpdateMessage {
delete(p.peerChannels, peerID) delete(p.peerChannels, peerID)
close(channel) close(channel)
} }
//mbragin: todo shouldn't it be more? or configurable? // mbragin: todo shouldn't it be more? or configurable?
channel := make(chan *UpdateMessage, channelBufferSize) channel := make(chan *UpdateMessage, channelBufferSize)
p.peerChannels[peerID] = channel p.peerChannels[peerID] = channel

View File

@ -3,6 +3,7 @@ package server
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -53,6 +54,8 @@ type User struct {
PATs map[string]*PersonalAccessToken PATs map[string]*PersonalAccessToken
// Blocked indicates whether the user is blocked. Blocked users can't use the system. // Blocked indicates whether the user is blocked. Blocked users can't use the system.
Blocked bool Blocked bool
// LastLogin is the last time the user logged in to IdP
LastLogin time.Time
} }
// IsBlocked returns true if the user is blocked, false otherwise // IsBlocked returns true if the user is blocked, false otherwise
@ -60,6 +63,10 @@ func (u *User) IsBlocked() bool {
return u.Blocked return u.Blocked
} }
func (u *User) LastDashboardLoginChanged(LastLogin time.Time) bool {
return LastLogin.After(u.LastLogin) && !u.LastLogin.IsZero()
}
// IsAdmin returns true if the user is an admin, false otherwise // IsAdmin returns true if the user is an admin, false otherwise
func (u *User) IsAdmin() bool { func (u *User) IsAdmin() bool {
return u.Role == UserRoleAdmin return u.Role == UserRoleAdmin
@ -82,6 +89,7 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) {
Status: string(UserStatusActive), Status: string(UserStatusActive),
IsServiceUser: u.IsServiceUser, IsServiceUser: u.IsServiceUser,
IsBlocked: u.Blocked, IsBlocked: u.Blocked,
LastLogin: u.LastLogin,
}, nil }, nil
} }
if userData.ID != u.Id { if userData.ID != u.Id {
@ -102,6 +110,7 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) {
Status: string(userStatus), Status: string(userStatus),
IsServiceUser: u.IsServiceUser, IsServiceUser: u.IsServiceUser,
IsBlocked: u.Blocked, IsBlocked: u.Blocked,
LastLogin: u.LastLogin,
}, nil }, nil
} }
@ -123,6 +132,7 @@ func (u *User) Copy() *User {
ServiceUserName: u.ServiceUserName, ServiceUserName: u.ServiceUserName,
PATs: pats, PATs: pats,
Blocked: u.Blocked, Blocked: u.Blocked,
LastLogin: u.LastLogin,
} }
} }
@ -186,6 +196,7 @@ func (am *DefaultAccountManager) createServiceUser(accountID string, initiatorUs
AutoGroups: newUser.AutoGroups, AutoGroups: newUser.AutoGroups,
Status: string(UserStatusActive), Status: string(UserStatusActive),
IsServiceUser: true, IsServiceUser: true,
LastLogin: time.Time{},
}, nil }, nil
} }
@ -280,6 +291,21 @@ func (am *DefaultAccountManager) GetUser(claims jwtclaims.AuthorizationClaims) (
if !ok { if !ok {
return nil, status.Errorf(status.NotFound, "user not found") return nil, status.Errorf(status.NotFound, "user not found")
} }
// this code should be outside of the am.GetAccountFromToken(claims) because this method is called also by the gRPC
// server when user authenticates a device. And we need to separate the Dashboard login event from the Device login event.
unlock := am.Store.AcquireAccountLock(account.Id)
newLogin := user.LastDashboardLoginChanged(claims.LastLogin)
err = am.Store.SaveUserLastLogin(account.Id, claims.UserId, claims.LastLogin)
unlock()
if newLogin {
meta := map[string]any{"timestamp": claims.LastLogin}
am.storeEvent(claims.UserId, claims.UserId, account.Id, activity.DashboardLogin, meta)
if err != nil {
log.Errorf("failed saving user last login: %v", err)
}
}
return user, nil return user, nil
} }

View File

@ -266,7 +266,8 @@ func TestUser_Copy(t *testing.T) {
LastUsed: time.Now(), LastUsed: time.Now(),
}, },
}, },
Blocked: false, Blocked: false,
LastLogin: time.Now(),
} }
err := validateStruct(user) err := validateStruct(user)