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