From 907f30f6771542a93a49d96280af8dec1f29b00e Mon Sep 17 00:00:00 2001 From: ale <ale@incal.net> Date: Wed, 14 Nov 2018 08:39:37 +0000 Subject: [PATCH] Add last password change timestamp to API Mapped to the shadowLastChange attribute in LDAP. --- backend/model.go | 52 +++++++++++++++++++++++++------------ backend/model_test.go | 9 +++++-- backend/testdata/test1.ldif | 4 +-- types.go | 10 ++++++- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/backend/model.go b/backend/model.go index 1db7cdcc..4d1d8a25 100644 --- a/backend/model.go +++ b/backend/model.go @@ -6,6 +6,7 @@ import ( "math/rand" "strconv" "strings" + "time" ldaputil "git.autistici.org/ai3/go-common/ldap" "github.com/tstranex/u2f" @@ -16,16 +17,17 @@ import ( const ( // Names of some well-known LDAP attributes. - totpSecretLDAPAttr = "totpSecret" - preferredLanguageLDAPAttr = "preferredLanguage" - recoveryHintLDAPAttr = "recoverQuestion" - recoveryResponseLDAPAttr = "recoverAnswer" - aspLDAPAttr = "appSpecificPassword" - storagePublicKeyLDAPAttr = "storagePublicKey" - storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey" - passwordLDAPAttr = "userPassword" - u2fRegistrationsLDAPAttr = "u2fRegistration" - uidNumberLDAPAttr = "uidNumber" + totpSecretLDAPAttr = "totpSecret" + preferredLanguageLDAPAttr = "preferredLanguage" + recoveryHintLDAPAttr = "recoverQuestion" + recoveryResponseLDAPAttr = "recoverAnswer" + aspLDAPAttr = "appSpecificPassword" + storagePublicKeyLDAPAttr = "storagePublicKey" + storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey" + passwordLDAPAttr = "userPassword" + passwordLastChangeLDAPAttr = "shadowLastChange" + u2fRegistrationsLDAPAttr = "u2fRegistration" + uidNumberLDAPAttr = "uidNumber" ) // backend is the interface to an LDAP-backed user database. @@ -125,12 +127,13 @@ func newUser(entry *ldap.Entry) (*as.RawUser, error) { uidNumber, _ := strconv.Atoi(entry.GetAttributeValue(uidNumberLDAPAttr)) // nolint user := &as.RawUser{ User: as.User{ - Name: entry.GetAttributeValue("uid"), - Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr), - UID: uidNumber, - AccountRecoveryHint: entry.GetAttributeValue(recoveryHintLDAPAttr), - U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)), - HasOTP: entry.GetAttributeValue(totpSecretLDAPAttr) != "", + Name: entry.GetAttributeValue("uid"), + Lang: entry.GetAttributeValue(preferredLanguageLDAPAttr), + UID: uidNumber, + LastPasswordChangeStamp: decodeShadowTimestamp(entry.GetAttributeValue(passwordLastChangeLDAPAttr)), + AccountRecoveryHint: entry.GetAttributeValue(recoveryHintLDAPAttr), + U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)), + HasOTP: entry.GetAttributeValue(totpSecretLDAPAttr) != "", }, // Remove the legacy LDAP {crypt} prefix on old passwords. Password: strings.TrimPrefix(entry.GetAttributeValue(passwordLDAPAttr), "{crypt}"), @@ -158,7 +161,7 @@ func userToLDAP(user *as.User) (attrs []ldap.PartialAttribute) { {Type: "gecos", Vals: s2l(user.Name)}, {Type: "loginShell", Vals: []string{"/bin/false"}}, {Type: "homeDirectory", Vals: []string{"/var/empty"}}, - {Type: "shadowLastChange", Vals: []string{"12345"}}, + {Type: passwordLastChangeLDAPAttr, Vals: []string{"12345"}}, {Type: "shadowWarning", Vals: []string{"7"}}, {Type: "shadowMax", Vals: []string{"99999"}}, {Type: preferredLanguageLDAPAttr, Vals: s2l(user.Lang)}, @@ -294,6 +297,7 @@ func (tx *backendTX) GetUser(ctx context.Context, username string) (*as.RawUser, func (tx *backendTX) SetUserPassword(ctx context.Context, user *as.User, encryptedPassword string) (err error) { dn := tx.getUserDN(user) tx.setAttr(dn, passwordLDAPAttr, encryptedPassword) + tx.setAttr(dn, passwordLastChangeLDAPAttr, encodeShadowTimestamp(time.Now())) for _, r := range user.GetResourcesByType(as.ResourceTypeEmail) { dn, err = tx.backend.resources.GetDN(r.ID) if err != nil { @@ -552,3 +556,17 @@ func (tx *backendTX) isUIDAvailable(ctx context.Context, uid int) (bool, error) } return true, nil } + +const oneDay = 86400 + +func encodeShadowTimestamp(t time.Time) string { + d := t.UTC().Unix() / oneDay + return strconv.FormatInt(d, 10) +} + +func decodeShadowTimestamp(s string) (t time.Time) { + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + t = time.Unix(i*oneDay, 0).UTC() + } + return +} diff --git a/backend/model_test.go b/backend/model_test.go index 16d42a3b..8b8f7c17 100644 --- a/backend/model_test.go +++ b/backend/model_test.go @@ -3,6 +3,7 @@ package backend import ( "context" "testing" + "time" "github.com/go-test/deep" @@ -79,10 +80,14 @@ func TestModel_GetUser(t *testing.T) { defer stop() if user.Name != testUser1 { - t.Fatalf("bad username: expected %s, got %s", testUser1, user.Name) + t.Errorf("bad username: expected %s, got %s", testUser1, user.Name) } if len(user.Resources) != 5 { - t.Fatalf("expected 5 resources, got %d", len(user.Resources)) + t.Errorf("expected 5 resources, got %d", len(user.Resources)) + } + expectedPwChangeStamp := time.Date(2018, 11, 14, 0, 0, 0, 0, time.UTC) + if user.LastPasswordChangeStamp != expectedPwChangeStamp { + t.Errorf("bad last password change timestamp: expected %s, got %s", expectedPwChangeStamp, user.LastPasswordChangeStamp) } // Test a specific resource (the database). diff --git a/backend/testdata/test1.ldif b/backend/testdata/test1.ldif index f0adac0f..30b3e650 100644 --- a/backend/testdata/test1.ldif +++ b/backend/testdata/test1.ldif @@ -16,7 +16,7 @@ sn: Private homeDirectory: /var/empty uid: uno@investici.org givenName: Private -shadowLastChange: 12345 +shadowLastChange: 17849 shadowWarning: 7 preferredLanguage: it userPassword:: JDYkbXBXN1NkdlE4bnY4UlpsTyRJNGZCV2RVSkV5VWxvR2l1WmdibzI1OVVUWkkyL3JWTlA4N1lT @@ -63,7 +63,7 @@ sn: Private homeDirectory: /home/users/investici.org/uno uid: uno creationDate: 01-08-2013 -shadowLastChange: 12345 +shadowLastChange: 17849 originalHost: host2 userPassword:: JDYkbXBXN1NkdlE4bnY4UlpsTyRJNGZCV2RVSkV5VWxvR2l1WmdibzI1OVVUWkkyL3JWTlA4N1lT TjNUTS53YXkyZHZSd1g2YTQ0dVVXZ2tYL1pzbkc4YXdHRFhYVGYwNU1VeE1saWdIMA== diff --git a/types.go b/types.go index ee4a3596..7a1da3d1 100644 --- a/types.go +++ b/types.go @@ -27,6 +27,10 @@ type User struct { // UNIX user id. UID int `json:"uid"` + // Timestamp of last password change. This is serialized as a + // RFC3339 string in JSON. + LastPasswordChangeStamp time.Time `json:"last_password_change_stamp"` + // Has2FA is true if the user has a second-factor authentication // mechanism properly set up. In practice, this is the case if either // HasOTP is true, or len(U2FRegistrations) > 0. @@ -36,15 +40,19 @@ type User struct { HasOTP bool `json:"has_otp"` // HasEncryptionKeys is true if encryption keys are properly set up for - // this user. TODO: consider disabling it. + // this user. HasEncryptionKeys bool `json:"has_encryption_keys"` + // The recovery hint for this account (empty if unset). AccountRecoveryHint string `json:"account_recovery_hint"` + // List of application-specific passwords (metadata only). AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"` + // List of U2F registrations. U2FRegistrations []*U2FRegistration `json:"u2f_registrations,omitempty"` + // All the resources owned by this user. Resources []*Resource `json:"resources,omitempty"` } -- GitLab