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 }