Commit 3a30acc6 authored by ale's avatar ale

Add password recovery logic

Implement a password recovery endpoint, and a way to set the recovery
hints (in the current model, it's a hint/response system).
parent 5f6e6818
......@@ -7,7 +7,6 @@ import (
"errors"
"git.autistici.org/ai3/go-common/pwhash"
"git.autistici.org/id/keystore/userenckey"
"github.com/pquerna/otp/totp"
)
......@@ -154,105 +153,176 @@ func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *Cha
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
// If the user does not yet have an encryption key, generate one now.
if !user.HasEncryptionKeys {
err = s.initializeUserEncryptionKeys(ctx, tx, user, req.Password)
} else {
err = s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID)
return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password)
})
}
// PasswordRecoveryRequest is the request type for AccountService.RecoverPassword().
// It is not authenticated with SSO.
type PasswordRecoveryRequest struct {
Username string `json:"username"`
RecoveryPassword string `json:"recovery_password"`
Password string `json:"password"`
}
// Validate the request.
func (r *PasswordRecoveryRequest) Validate(ctx context.Context, s *AccountService) error {
return s.passwordValidator(ctx, r.Password)
}
// RecoverPassword lets users reset their password by providing
// secondary credentials, which we authenticate ourselves.
//
// TODO: call out to auth-server for secondary authentication?
func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *PasswordRecoveryRequest) error {
user, err := s.getUser(ctx, tx, req.Username)
if err != nil {
return err
}
// TODO: authenticate with the secret recovery password.
ctx = context.WithValue(ctx, authUserCtxKey, req.Username)
return s.withRequest(ctx, req, func(ctx context.Context) error {
// Change the user password (the recovery password does not change).
return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password)
})
}
// ResetPasswordRequest is the request type for AccountService.ResetPassword().
type ResetPasswordRequest struct {
RequestBase
Password string `json:"password"`
}
// Validate the request.
func (r *ResetPasswordRequest) Validate(ctx context.Context, s *AccountService) error {
return s.passwordValidator(ctx, r.Password)
}
// ResetPassword is an admin operation to forcefully reset the
// password for an account. The user will lose access to all stored
// email (because the encryption keys will be reset) and to 2FA.
func (s *AccountService) ResetPassword(ctx context.Context, tx TX, req *ResetPasswordRequest) error {
ctx, user, err := s.authorizeAdmin(ctx, tx, req.RequestBase)
if err != nil {
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
// Disable 2FA.
if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
return err
}
// Reset encryption keys and set the new password.
return s.changeUserPasswordAndResetEncryptionKeys(ctx, tx, user, req.Password)
})
}
// SetPasswordRecoveryHintRequest is the request type for
// AccountService.SetPasswordRecoveryHint().
type SetPasswordRecoveryHintRequest struct {
PrivilegedRequestBase
Hint string `json:"recovery_hint"`
Response string `json:"recovery_response"`
}
// Validate the request.
func (r *SetPasswordRecoveryHintRequest) Validate(ctx context.Context, s *AccountService) error {
return s.passwordValidator(ctx, r.Response)
}
// SetPasswordRecoveryHint lets users set the password recovery hint
// and expected response (secondary password).
func (s *AccountService) SetPasswordRecoveryHint(ctx context.Context, tx TX, req *SetPasswordRecoveryHintRequest) error {
ctx, user, err := s.authorizeUserWithPassword(ctx, tx, req.PrivilegedRequestBase)
if err != nil {
return err
}
return s.withRequest(ctx, req, func(ctx context.Context) error {
// If the encryption keys are not set up yet, use the
// CurPassword to initialize them.
keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
if err != nil {
return err
}
keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyRecoveryID, req.Response)
if err != nil {
return err
}
// Set the encrypted password attribute on the user (will set it on emails too).
encPass := pwhash.Encrypt(req.Password)
if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
return newBackendError(err)
}
return nil
encResponse := pwhash.Encrypt(req.Response)
return tx.SetPasswordRecoveryHint(ctx, user, req.Hint, encResponse)
})
}
// Initialize the user encryption key list, by creating a new "main" key
// encrypted with the given password (which must be the primary password for the
// user).
func (s *AccountService) initializeUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword string) error {
// Generate a new key pair.
pub, priv, err := userenckey.GenerateKey()
// Change the user password and update encryption keys, provided we
// have a password that we can use to decrypt them first.
func (s *AccountService) changeUserPasswordAndUpdateEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) error {
// If the user does not yet have an encryption key, generate one now.
var err error
keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, oldPassword, newPassword)
if err != nil {
return err
}
// Encrypt the private key with the password.
enc, err := userenckey.Encrypt(priv, []byte(curPassword))
keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyMainID, newPassword)
if err != nil {
return err
}
keys := []*UserEncryptionKey{
&UserEncryptionKey{
ID: UserEncryptionKeyMainID,
Key: enc,
},
}
// Update the backend database.
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
return newBackendError(err)
}
if err := tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
// Set the encrypted password attribute on the user (will set it on emails too).
encPass := pwhash.Encrypt(newPassword)
if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
return newBackendError(err)
}
return nil
}
// Re-encrypt the specified user encryption key with newPassword. For this
// operation to succeed, we must be able to decrypt one of the keys (not
// necessarily the same one) with curPassword.
func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword, newPassword, keyID string) error {
keys, err := tx.GetUserEncryptionKeys(ctx, user)
// Change the user password and reset all encryption keys. Existing email
// won't be readable anymore. Existing 2FA credentials will be deleted.
func (s *AccountService) changeUserPasswordAndResetEncryptionKeys(ctx context.Context, tx TX, user *User, newPassword string) error {
// Calling initialize will wipe the current keys and replace
// them with a new one.
keys, _, err := s.initializeEncryptionKeys(ctx, tx, user, newPassword)
if err != nil {
return newBackendError(err)
}
keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
if err != nil {
return newRequestError(err)
return err
}
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
return newBackendError(err)
}
return nil
}
// Decode the user encyrption key using the given password, then generate a new
// list of encryption keys by replacing the specified encryption key with one
// encrypted with the given password (or adding it if it does not exist).
func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyID string) ([]*UserEncryptionKey, error) {
// userenckey.Decrypt wants a slice of []byte.
var rawKeys [][]byte
for _, k := range keys {
rawKeys = append(rawKeys, k.Key)
}
decrypted, err := userenckey.Decrypt(rawKeys, []byte(curPassword))
if err != nil {
return nil, err
// Wipe all 2FA credentials that had corresponding encryption keys as well.
if err := s.wipeApplicationSpecificPasswords(ctx, tx, user); err != nil {
return err
}
encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword))
if err != nil {
return nil, err
// Set the encrypted password attribute on the user (will set it on emails too).
encPass := pwhash.Encrypt(newPassword)
if err := tx.SetUserPassword(ctx, user, encPass); err != nil {
return newBackendError(err)
}
return nil
}
var keysOut []*UserEncryptionKey
for _, key := range keys {
if key.ID != keyID {
keysOut = append(keysOut, key)
func (s *AccountService) wipeApplicationSpecificPasswords(ctx context.Context, tx TX, user *User) error {
for _, asp := range user.AppSpecificPasswords {
if err := tx.DeleteApplicationSpecificPassword(ctx, user, asp.ID); err != nil {
return err
}
}
keysOut = append(keysOut, &UserEncryptionKey{
ID: keyID,
Key: encrypted,
})
return keysOut, nil
return nil
}
// CreateApplicationSpecificPasswordRequest is the request type for
......@@ -310,10 +380,18 @@ func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context,
// this password. The user encryption key IDs for ASPs all
// have an 'asp_' prefix, followed by the ASP ID.
if user.HasEncryptionKeys {
keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword)
if err != nil {
return err
}
keyID := "asp_" + asp.ID
if err := s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, password, keyID); err != nil {
keys, err = updateEncryptionKey(keys, decrypted, keyID, password)
if err != nil {
return err
}
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
return newBackendError(err)
}
}
resp.Password = password
......
......@@ -40,6 +40,10 @@ func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password st
return nil
}
func (b *fakeBackend) SetPasswordRecoveryHint(_ context.Context, user *User, hint, response string) error {
return nil
}
func (b *fakeBackend) SetResourcePassword(_ context.Context, r *Resource, password string) error {
return nil
}
......@@ -252,19 +256,25 @@ func TestService_EncryptionKeys(t *testing.T) {
user, _ := svc.getUser(ctx, tx, "testuser")
if err := svc.initializeUserEncryptionKeys(ctx, tx, user, "password"); err != nil {
// Set the keys to something.
keys, _, err := svc.initializeEncryptionKeys(ctx, tx, user, "password")
if err != nil {
t.Fatal("init", err)
}
if err := svc.updateUserEncryptionKeys(ctx, tx, user, "BADPASS", "new_password", UserEncryptionKeyMainID); err == nil {
t.Fatal("update with bad password did not fail")
}
if err := svc.updateUserEncryptionKeys(ctx, tx, user, "password", "new_password", UserEncryptionKeyMainID); err != nil {
t.Fatal("update", err)
if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil {
t.Fatal("SetUserEncryptionKeys", err)
}
if n := len(fb.encryptionKeys["testuser"]); n != 1 {
t.Fatalf("found %d encryption keys, expected 1", n)
}
// Try to read (decrypt) them again using bad / good passwords.
if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "BADPASS", "new_password"); err == nil {
t.Fatal("read with bad password did not fail")
}
if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "password", "new_password"); err != nil {
t.Fatal("readOrInitialize", err)
}
}
// Try adding aliases to the email resource.
......
......@@ -14,7 +14,8 @@ const (
// Names of some well-known LDAP attributes.
totpSecretLDAPAttr = "totpSecret"
preferredLanguageLDAPAttr = "preferredLanguage"
recoverQuestionLDAPAttr = "recoverQuestion"
recoveryHintLDAPAttr = "recoverQuestion"
recoveryResponseLDAPAttr = "recoverAnswer"
aspLDAPAttr = "appSpecificPassword"
storagePublicKeyLDAPAttr = "storagePublicKey"
storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey"
......@@ -144,7 +145,7 @@ func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserv
// object, a shortcoming of the legacy A/I database model. Set
// them on the main User object.
if isObjectClass(entry, "virtualMailUser") {
user.PasswordRecoveryHint = entry.GetAttributeValue(recoverQuestionLDAPAttr)
user.PasswordRecoveryHint = entry.GetAttributeValue(recoveryHintLDAPAttr)
user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues(aspLDAPAttr)))
user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "")
}
......@@ -171,6 +172,13 @@ func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.Us
return nil
}
func (tx *backendTX) SetPasswordRecoveryHint(ctx context.Context, user *accountserver.User, hint, response string) error {
dn := tx.getUserDN(user)
tx.setAttr(dn, recoveryHintLDAPAttr, hint)
tx.setAttr(dn, recoveryResponseLDAPAttr, response)
return nil
}
func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
r := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
dn, _ := tx.backend.resources.GetDN(r.ID)
......
package accountserver
import (
"context"
"git.autistici.org/id/keystore/userenckey"
)
func keysToBytes(keys []*UserEncryptionKey) [][]byte {
var rawKeys [][]byte
for _, k := range keys {
rawKeys = append(rawKeys, k.Key)
}
return rawKeys
}
func (s *AccountService) initializeEncryptionKeys(ctx context.Context, tx TX, user *User, password string) (keys []*UserEncryptionKey, decrypted []byte, err error) {
// Create new keys
pub, priv, err := userenckey.GenerateKey()
if err != nil {
return nil, nil, err
}
decrypted = priv
enc, err := userenckey.Encrypt(priv, []byte(password))
if err != nil {
return nil, nil, err
}
keys = append(keys, &UserEncryptionKey{
ID: UserEncryptionKeyMainID,
Key: enc,
})
// Save the new public key.
if err = tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil {
err = newBackendError(err)
}
return
}
func (s *AccountService) readOrInitializeEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) (keys []*UserEncryptionKey, decrypted []byte, err error) {
if user.HasEncryptionKeys {
// Fetch the encryption keys from the database.
keys, err = tx.GetUserEncryptionKeys(ctx, user)
if err != nil {
return
}
decrypted, err = userenckey.Decrypt(keysToBytes(keys), []byte(oldPassword))
return
}
return s.initializeEncryptionKeys(ctx, tx, user, newPassword)
}
func updateEncryptionKey(keys []*UserEncryptionKey, decrypted []byte, keyID, password string) ([]*UserEncryptionKey, error) {
encrypted, err := userenckey.Encrypt(decrypted, []byte(password))
if err != nil {
return nil, err
}
// Replace the key with id 'keyID' in the in-memory list.
var keysOut []*UserEncryptionKey
for _, key := range keys {
if key.ID != keyID {
keysOut = append(keysOut, key)
}
}
keysOut = append(keysOut, &UserEncryptionKey{
ID: keyID,
Key: encrypted,
})
return keysOut, nil
}
......@@ -44,6 +44,20 @@ func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter
})
}
func (s *AccountServer) handleSetPasswordRecoveryHint(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
var req as.SetPasswordRecoveryHintRequest
return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
return &emptyResponse, s.service.SetPasswordRecoveryHint(ctx, tx, &req)
})
}
func (s *AccountServer) handleRecoverPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
var req as.PasswordRecoveryRequest
return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
return &emptyResponse, s.service.RecoverPassword(ctx, tx, &req)
})
}
func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) {
var req as.CreateApplicationSpecificPasswordRequest
return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) {
......@@ -122,6 +136,7 @@ func (s *AccountServer) Handler() http.Handler {
h := http.NewServeMux()
h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser))
h.HandleFunc("/api/user/change_password", s.withTx(s.handleChangeUserPassword))
h.HandleFunc("/api/user/set_password_recovery_hint", s.withTx(s.handleSetPasswordRecoveryHint))
h.HandleFunc("/api/user/enable_otp", s.withTx(s.handleEnableOTP))
h.HandleFunc("/api/user/disable_otp", s.withTx(s.handleDisableOTP))
h.HandleFunc("/api/app_specific_password/create", s.withTx(s.handleCreateApplicationSpecificPassword))
......@@ -129,6 +144,7 @@ func (s *AccountServer) Handler() http.Handler {
h.HandleFunc("/api/resource/enable", s.withTx(s.handleEnableResource))
h.HandleFunc("/api/resource/disable", s.withTx(s.handleDisableResource))
h.HandleFunc("/api/resource/move", s.withTx(s.handleMoveResource))
h.HandleFunc("/api/recover_password", s.withTx(s.handleRecoverPassword))
return h
}
......
......@@ -42,6 +42,7 @@ type TX interface {
GetUser(context.Context, string) (*User, error)
SetUserPassword(context.Context, *User, string) error
SetPasswordRecoveryHint(context.Context, *User, string, string) error
GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error
SetUserEncryptionPublicKey(context.Context, *User, []byte) error
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment