github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/session/login_history.go (about)

     1  package session
     2  
     3  import (
     4  	"net"
     5  	"net/http"
     6  	"net/url"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	"github.com/cozy/cozy-stack/pkg/config/config"
    12  	"github.com/cozy/cozy-stack/pkg/consts"
    13  	"github.com/cozy/cozy-stack/pkg/couchdb"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    15  	"github.com/cozy/cozy-stack/pkg/emailer"
    16  	"github.com/cozy/cozy-stack/pkg/i18n"
    17  	"github.com/cozy/cozy-stack/pkg/logger"
    18  	"github.com/labstack/echo/v4"
    19  	"github.com/mssola/user_agent"
    20  	maxminddb "github.com/oschwald/maxminddb-golang"
    21  )
    22  
    23  // LoginEntry stores informations associated with a new login. It is useful to
    24  // provide the user with informations about the history of all the logins that
    25  // may have happened on its domain.
    26  type LoginEntry struct {
    27  	DocID       string `json:"_id,omitempty"`
    28  	DocRev      string `json:"_rev,omitempty"`
    29  	SessionID   string `json:"session_id"`
    30  	IP          string `json:"ip"`
    31  	City        string `json:"city,omitempty"`
    32  	Subdivision string `json:"subdivision,omitempty"`
    33  	Country     string `json:"country,omitempty"`
    34  	// XXX No omitempty on os and browser, because they are indexed in couchdb
    35  	UA                 string    `json:"user_agent"`
    36  	OS                 string    `json:"os"`
    37  	Browser            string    `json:"browser"`
    38  	ClientRegistration bool      `json:"client_registration"`
    39  	CreatedAt          time.Time `json:"created_at"`
    40  }
    41  
    42  // DocType implements couchdb.Doc
    43  func (l *LoginEntry) DocType() string { return consts.SessionsLogins }
    44  
    45  // ID implements couchdb.Doc
    46  func (l *LoginEntry) ID() string { return l.DocID }
    47  
    48  // SetID implements couchdb.Doc
    49  func (l *LoginEntry) SetID(v string) { l.DocID = v }
    50  
    51  // Rev implements couchdb.Doc
    52  func (l *LoginEntry) Rev() string { return l.DocRev }
    53  
    54  // SetRev implements couchdb.Doc
    55  func (l *LoginEntry) SetRev(v string) { l.DocRev = v }
    56  
    57  // Clone implements couchdb.Doc
    58  func (l *LoginEntry) Clone() couchdb.Doc {
    59  	clone := *l
    60  	return &clone
    61  }
    62  
    63  func lookupIP(ip, locale string) (city, subdivision, country, timezone string) {
    64  	geodb := config.GetConfig().GeoDB
    65  	if geodb == "" {
    66  		return
    67  	}
    68  	db, err := maxminddb.Open(geodb)
    69  	if err != nil {
    70  		logger.WithNamespace("sessions").Errorf("cannot open the geodb: %s", err)
    71  		return
    72  	}
    73  	defer db.Close()
    74  
    75  	var record struct {
    76  		City struct {
    77  			Names map[string]string `maxminddb:"names"`
    78  		} `maxminddb:"city"`
    79  		Subdivisions []struct {
    80  			Names map[string]string `maxminddb:"names"`
    81  		} `maxminddb:"subdivisions"`
    82  		Country struct {
    83  			Names map[string]string `maxminddb:"names"`
    84  		} `maxminddb:"country"`
    85  		Location struct {
    86  			TimeZone string `maxminddb:"time_zone"`
    87  		} `maxminddb:"location"`
    88  	}
    89  
    90  	err = db.Lookup(net.ParseIP(ip), &record)
    91  	if err != nil {
    92  		logger.WithNamespace("sessions").Infof("cannot lookup %s: %s", ip, err)
    93  		return
    94  	}
    95  	if c, ok := record.City.Names[locale]; ok {
    96  		city = c
    97  	} else if c, ok := record.City.Names["en"]; ok {
    98  		city = c
    99  	}
   100  	if len(record.Subdivisions) > 0 {
   101  		if s, ok := record.Subdivisions[0].Names[locale]; ok {
   102  			subdivision = s
   103  		} else if s, ok := record.Subdivisions[0].Names["en"]; ok {
   104  			city = s
   105  		}
   106  	}
   107  	if c, ok := record.Country.Names[locale]; ok {
   108  		country = c
   109  	} else if c, ok := record.Country.Names["en"]; ok {
   110  		country = c
   111  	}
   112  	timezone = record.Location.TimeZone
   113  	return
   114  }
   115  
   116  // StoreNewLoginEntry creates a new login entry in the database associated with
   117  // the given instance.
   118  func StoreNewLoginEntry(i *instance.Instance, sessionID, clientID string,
   119  	req *http.Request, logMessage string, notifEnabled bool,
   120  ) error {
   121  	var ip string
   122  	if forwardedFor := req.Header.Get(echo.HeaderXForwardedFor); forwardedFor != "" {
   123  		ip = strings.TrimSpace(strings.SplitN(forwardedFor, ",", 2)[0])
   124  	}
   125  	if ip == "" {
   126  		ip = strings.Split(req.RemoteAddr, ":")[0]
   127  	}
   128  
   129  	city, subdivision, country, timezone := lookupIP(ip, i.Locale)
   130  	rawUserAgent := req.UserAgent()
   131  	ua := user_agent.New(rawUserAgent)
   132  	os := ua.OS()
   133  	browser, _ := ua.Browser()
   134  	if strings.Contains(rawUserAgent, "CozyDrive") {
   135  		browser = "CozyDrive"
   136  	}
   137  
   138  	createdAt := time.Now()
   139  	i.Logger().WithNamespace("loginaudit").
   140  		Infof("New connection from %s at %s (%s)", ip, createdAt, logMessage)
   141  	if timezone != "" {
   142  		if loc, err := time.LoadLocation(timezone); err == nil {
   143  			createdAt = createdAt.In(loc)
   144  		}
   145  	}
   146  
   147  	l := &LoginEntry{
   148  		IP:                 ip,
   149  		SessionID:          sessionID,
   150  		City:               city,
   151  		Subdivision:        subdivision,
   152  		Country:            country,
   153  		UA:                 req.UserAgent(),
   154  		OS:                 os,
   155  		Browser:            browser,
   156  		ClientRegistration: clientID != "",
   157  		CreatedAt:          createdAt,
   158  	}
   159  
   160  	if err := couchdb.CreateDoc(i, l); err != nil {
   161  		return err
   162  	}
   163  
   164  	if clientID != "" {
   165  		if err := PushLoginRegistration(i, l, clientID); err != nil {
   166  			i.Logger().Errorf("Could not push login in registration queue: %s", err)
   167  		}
   168  	} else if notifEnabled {
   169  		if err := sendLoginNotification(i, l); err != nil {
   170  			i.Logger().Errorf("Could not send login notification: %s", err)
   171  		}
   172  	}
   173  
   174  	return nil
   175  }
   176  
   177  func sendLoginNotification(i *instance.Instance, l *LoginEntry) error {
   178  	// Don't send a notification the first time the user logs in their Cozy, as
   179  	// it doesn't make sense for the user. In general, this function is not
   180  	// even called when this is the case, but sometimes the user can create
   181  	// their Cozy from the manager with an OIDC flow, with no confirmation mail
   182  	// no password choosing, and we need this trick for them.
   183  	nb, _ := couchdb.CountNormalDocs(i, consts.SessionsLogins)
   184  	if nb == 1 {
   185  		return nil
   186  	}
   187  
   188  	var results []*LoginEntry
   189  	r := &couchdb.FindRequest{
   190  		UseIndex: "by-os-browser-ip",
   191  		Selector: mango.And(
   192  			mango.Equal("os", l.OS),
   193  			mango.Equal("browser", l.Browser),
   194  			mango.Equal("ip", l.IP),
   195  			mango.NotEqual("_id", l.ID()),
   196  		),
   197  		Limit: 1,
   198  	}
   199  	err := couchdb.FindDocs(i, consts.SessionsLogins, r, &results)
   200  	sendNotification := err != nil || len(results) == 0
   201  	if !sendNotification {
   202  		return nil
   203  	}
   204  
   205  	var changePassphraseLink string
   206  	if !i.HasForcedOIDC() {
   207  		changePassphraseLink = i.ChangePasswordURL()
   208  	}
   209  	var activateTwoFALink string
   210  	if !i.HasAuthMode(instance.TwoFactorMail) {
   211  		settingsURL := i.SubDomain(consts.SettingsSlug)
   212  		settingsURL.Fragment = "/profile"
   213  		activateTwoFALink = settingsURL.String()
   214  	}
   215  
   216  	layout := i.Translate("Time Format Long")
   217  	time := i18n.LocalizeTime(l.CreatedAt, i.Locale, layout)
   218  
   219  	templateValues := map[string]interface{}{
   220  		"Time":                 time,
   221  		"Country":              l.Country,
   222  		"IP":                   l.IP,
   223  		"Browser":              l.Browser,
   224  		"OS":                   l.OS,
   225  		"ChangePassphraseLink": changePassphraseLink,
   226  		"ActivateTwoFALink":    activateTwoFALink,
   227  	}
   228  
   229  	return emailer.SendEmail(i, &emailer.TransactionalEmailCmd{
   230  		TemplateName:   "new_connection",
   231  		TemplateValues: templateValues,
   232  	})
   233  }
   234  
   235  // SendNewRegistrationNotification is used to send a notification to the user
   236  // when a new OAuth client is registered.
   237  func SendNewRegistrationNotification(i *instance.Instance, clientRegistrationID string) error {
   238  	devicesLink := i.SubDomain(consts.SettingsSlug)
   239  	devicesLink.Fragment = "/connectedDevices"
   240  	revokeLink := i.SubDomain(consts.SettingsSlug)
   241  	revokeLink.Fragment = "/connectedDevices/" + url.PathEscape(clientRegistrationID)
   242  	templateValues := map[string]interface{}{
   243  		"DevicesLink": devicesLink.String(),
   244  		"RevokeLink":  revokeLink.String(),
   245  	}
   246  
   247  	return emailer.SendEmail(i, &emailer.TransactionalEmailCmd{
   248  		TemplateName:   "new_registration",
   249  		TemplateValues: templateValues,
   250  	})
   251  }