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 {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
AutoGroups []string `json:"auto_groups"`
Status string `json:"-"`
IsServiceUser bool `json:"is_service_user"`
IsBlocked bool `json:"is_blocked"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
AutoGroups []string `json:"auto_groups"`
Status string `json:"-"`
IsServiceUser bool `json:"is_service_user"`
IsBlocked bool `json:"is_blocked"`
LastLogin time.Time `json:"last_login"`
}
// getRoutesToSync returns the enabled routes for the peer ID and the routes

View File

@ -110,6 +110,8 @@ const (
UserLoggedInPeer
// PeerLoginExpired indicates that the user peer login has been expired and peer disconnected
PeerLoginExpired
// DashboardLogin indicates that the user logged in to the dashboard
DashboardLogin
)
var activityMap = map[Activity]Code{
@ -163,6 +165,7 @@ var activityMap = map[Activity]Code{
GroupDeleted: {"Group deleted", "group.delete"},
UserLoggedInPeer: {"User logged in peer", "user.peer.login"},
PeerLoginExpired: {"Peer login expired", "peer.login.expire"},
DashboardLogin: {"Dashboard login", "dashboard.login"},
}
// 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
}
// 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
func (s *FileStore) Close() error {
s.mux.Lock()

View File

@ -100,6 +100,11 @@ components:
type: string
enum: [ "active","invited","blocked" ]
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:
description: Groups to auto-assign to peers registered by this user
type: array

View File

@ -767,6 +767,9 @@ type User struct {
// IsServiceUser Is true if this user is a service user
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 string `json:"name"`

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package jwtclaims
import (
"net/http"
"time"
"github.com/golang-jwt/jwt"
)
@ -17,6 +18,8 @@ const (
DomainCategorySuffix = "wt_account_domain_category"
// UserIDClaim claim for the user id
UserIDClaim = "sub"
// LastLoginSuffix claim for the last login
LastLoginSuffix = "nb_last_login"
)
// ExtractClaims Extract function type
@ -93,9 +96,24 @@ func (c *ClaimsExtractor) FromToken(token *jwt.Token) AuthorizationClaims {
if ok {
jwtClaims.DomainCategory = domainCategoryClaim.(string)
}
LastLoginClaimString, ok := claims[c.authAudience+LastLoginSuffix]
if ok {
jwtClaims.LastLogin = parseTime(LastLoginClaimString.(string))
}
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)
func (c *ClaimsExtractor) fromRequestContext(r *http.Request) AuthorizationClaims {
if r.Context().Value(TokenUserProperty) == nil {

View File

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

View File

@ -1,5 +1,7 @@
package server
import "time"
type Store interface {
GetAllAccounts() []*Account
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() func()
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() error
}

View File

@ -1,9 +1,11 @@
package server
import (
"github.com/netbirdio/netbird/management/proto"
log "github.com/sirupsen/logrus"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/proto"
)
const channelBufferSize = 100
@ -33,7 +35,7 @@ func (p *PeersUpdateManager) SendUpdate(peerID string, update *UpdateMessage) er
if channel, ok := p.peerChannels[peerID]; ok {
select {
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:
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)
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)
p.peerChannels[peerID] = channel

View File

@ -3,6 +3,7 @@ package server
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@ -53,6 +54,8 @@ type User struct {
PATs map[string]*PersonalAccessToken
// Blocked indicates whether the user is blocked. Blocked users can't use the system.
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
@ -60,6 +63,10 @@ func (u *User) IsBlocked() bool {
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
func (u *User) IsAdmin() bool {
return u.Role == UserRoleAdmin
@ -82,6 +89,7 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) {
Status: string(UserStatusActive),
IsServiceUser: u.IsServiceUser,
IsBlocked: u.Blocked,
LastLogin: u.LastLogin,
}, nil
}
if userData.ID != u.Id {
@ -102,6 +110,7 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) {
Status: string(userStatus),
IsServiceUser: u.IsServiceUser,
IsBlocked: u.Blocked,
LastLogin: u.LastLogin,
}, nil
}
@ -123,6 +132,7 @@ func (u *User) Copy() *User {
ServiceUserName: u.ServiceUserName,
PATs: pats,
Blocked: u.Blocked,
LastLogin: u.LastLogin,
}
}
@ -186,6 +196,7 @@ func (am *DefaultAccountManager) createServiceUser(accountID string, initiatorUs
AutoGroups: newUser.AutoGroups,
Status: string(UserStatusActive),
IsServiceUser: true,
LastLogin: time.Time{},
}, nil
}
@ -280,6 +291,21 @@ func (am *DefaultAccountManager) GetUser(claims jwtclaims.AuthorizationClaims) (
if !ok {
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
}

View File

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