diff --git a/API.md b/API.md index 43e513962d6feacd4c2b2ed9f523cf1c691df8da..ccd2d2511a586a22048bff0114c4b009162dd57a 100644 --- a/API.md +++ b/API.md @@ -314,6 +314,21 @@ Request parameters: * `sso` - SSO ticket * `asp_id` - ID of the app-specific password +### `/api/user/update` + +Allows the caller to update a (very limited) selected set of fields on +a user object. It is a catch-all function for very simple changes that +don't justify their own specialized method. + +Non-authentication request attributes are all optional. + +Request parameters: + +* `username` - user to fetch +* `sso` - SSO ticket +* `lang` - set the preferred language for this user +* `u2f_registrations` - set the list of U2F registrations + ### `/api/user/create` Create a new user (admin-only). Will also create all the resources diff --git a/actions.go b/actions.go index 6e2ed414ccb640187e4da643647eac104f52d48a..bb2dc5e9e619d71bac4158fecf154ee26b2b8ad7 100644 --- a/actions.go +++ b/actions.go @@ -11,6 +11,7 @@ import ( "git.autistici.org/ai3/go-common/pwhash" "github.com/pquerna/otp/totp" "github.com/sethvargo/go-password/password" + "github.com/tstranex/u2f" ) // RequestBase contains parameters shared by all request types. @@ -839,6 +840,50 @@ func (s *AccountService) CreateUser(ctx context.Context, tx TX, req *CreateUserR return &resp, err } +// UpdateUserRequest is the request type for AccountService.UpdateUser(). +type UpdateUserRequest struct { + RequestBase + + Lang string `json:"lang,omitempty"` + U2FRegistrations []*u2f.Registration `json:"u2f_registrations,omitempty"` +} + +// Validate the request. +func (r *UpdateUserRequest) Validate(_ context.Context, _ *AccountService) error { + if len(r.U2FRegistrations) > 20 { + return errors.New("too many U2F registrations") + } + + // TODO: better validation of the language code! + if len(r.Lang) > 2 { + return errors.New("invalid language code") + } + + return nil +} + +// UpdateUser allows the caller to update a (very limited) selected +// set of fields on a User object. It is a catch-all function for very +// simple changes that don't justify their own specialized method. +func (s *AccountService) UpdateUser(ctx context.Context, tx TX, req *UpdateUserRequest) (*User, error) { + var resp *User + err := s.handleUserRequest(ctx, tx, req, s.authUser(req.RequestBase), func(ctx context.Context, user *User) error { + if req.Lang != "" { + user.Lang = req.Lang + } + + // TODO: check if this allows the caller to use an + // empty list to unset the field completely. + if req.U2FRegistrations != nil { + user.U2FRegistrations = req.U2FRegistrations + } + + resp = user + return tx.UpdateUser(ctx, user) + }) + return resp, err +} + func randomBase64(n int) string { b := make([]byte, n*3/4) _, err := rand.Read(b[:]) diff --git a/actions_test.go b/actions_test.go index 81cda0784dec58e490311423547ae683ca5919e7..ed87188ad328f816d7ca33701ff8ef7ad1ddd6dc 100644 --- a/actions_test.go +++ b/actions_test.go @@ -28,6 +28,11 @@ func (b *fakeBackend) GetUser(_ context.Context, username string) (*User, error) return b.users[username], nil } +func (b *fakeBackend) UpdateUser(_ context.Context, user *User) error { + b.users[user.Name] = user + return nil +} + func (b *fakeBackend) CreateUser(_ context.Context, user *User) error { b.users[user.Name] = user return nil diff --git a/backend/model.go b/backend/model.go index e2db1b4ccc47f56d01d451ce03548b7e273d5d29..aa7590b19eb3694d7f30e9bfef7d8c0d27ac5d6e 100644 --- a/backend/model.go +++ b/backend/model.go @@ -5,6 +5,7 @@ import ( "strings" ldaputil "git.autistici.org/ai3/go-common/ldap" + "github.com/tstranex/u2f" "gopkg.in/ldap.v2" "git.autistici.org/ai3/accountserver" @@ -20,6 +21,7 @@ const ( storagePublicKeyLDAPAttr = "storagePublicKey" storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey" passwordLDAPAttr = "userPassword" + u2fRegistrationsLDAPAttr = "u2fRegistration" ) // backend is the interface to an LDAP-backed user database. @@ -103,6 +105,7 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) { Has2FA: (entry.GetAttributeValue(totpSecretLDAPAttr) != ""), //HasEncryptionKeys: (len(entry.GetAttributeValues("storageEncryptionKey")) > 0), //PasswordRecoveryHint: entry.GetAttributeValue("recoverQuestion"), + U2FRegistrations: decodeU2FRegistrations(entry.GetAttributeValues(u2fRegistrationsLDAPAttr)), } if user.Lang == "" { user.Lang = "en" @@ -125,10 +128,42 @@ func userToLDAP(user *accountserver.User) (attrs []ldap.PartialAttribute) { {Type: "shadowWarning", Vals: []string{"7"}}, {Type: "shadowMax", Vals: []string{"99999"}}, {Type: preferredLanguageLDAPAttr, Vals: s2l(user.Lang)}, + {Type: u2fRegistrationsLDAPAttr, Vals: encodeU2FRegistrations(user.U2FRegistrations)}, }...) return } +func decodeU2FRegistration(enc string) (*u2f.Registration, error) { + var reg u2f.Registration + if err := reg.UnmarshalBinary([]byte(enc)); err != nil { + return nil, err + } + return ®, nil +} + +func encodeU2FRegistration(r *u2f.Registration) string { + b, _ := r.MarshalBinary() + return string(b) +} + +func decodeU2FRegistrations(encRegs []string) []*u2f.Registration { + var out []*u2f.Registration + for _, enc := range encRegs { + if r, err := decodeU2FRegistration(enc); err == nil { + out = append(out, r) + } + } + return out +} + +func encodeU2FRegistrations(regs []*u2f.Registration) []string { + var out []string + for _, r := range regs { + out = append(out, encodeU2FRegistration(r)) + } + return out +} + func (tx *backendTX) getUserDN(user *accountserver.User) string { return joinDN("uid="+user.Name, "ou=People", tx.backend.baseDN) } @@ -152,6 +187,15 @@ func (tx *backendTX) CreateUser(ctx context.Context, user *accountserver.User) e return nil } +// UpdateUser updates values for the user only (not the resources). +func (tx *backendTX) UpdateUser(ctx context.Context, user *accountserver.User) error { + dn := tx.getUserDN(user) + for _, attr := range userToLDAP(user) { + tx.setAttr(dn, attr.Type, attr.Vals...) + } + return nil +} + // GetUser returns a user. func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserver.User, error) { // First of all, find the main user object, and just that one. diff --git a/server/server.go b/server/server.go index f2948f376f0e96a46fe803632230263ccd0c085e..ce8efa4c07cab73776cc697e86f0c0a4f5c45cc3 100644 --- a/server/server.go +++ b/server/server.go @@ -44,6 +44,13 @@ func (s *AccountServer) handleCreateUser(tx as.TX, w http.ResponseWriter, r *htt }) } +func (s *AccountServer) handleUpdateUser(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.UpdateUserRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return s.service.UpdateUser(ctx, tx, &req) + }) +} + func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.ChangeUserPasswordRequest return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { @@ -171,6 +178,7 @@ func (s *AccountServer) Handler() http.Handler { h := http.NewServeMux() h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser)) h.HandleFunc("/api/user/create", s.withTx(s.handleCreateUser)) + h.HandleFunc("/api/user/update", s.withTx(s.handleUpdateUser)) 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)) diff --git a/service.go b/service.go index bfb8c10fa3b486a93185bb004b4f7f37ee797a4b..8b46fcb1d24b11fc3be02b0e76f4cd2c5574ff55 100644 --- a/service.go +++ b/service.go @@ -43,6 +43,7 @@ type TX interface { HasAnyResource(context.Context, []FindResourceRequest) (bool, error) GetUser(context.Context, string) (*User, error) + UpdateUser(context.Context, *User) error CreateUser(context.Context, *User) error SetUserPassword(context.Context, *User, string) error SetPasswordRecoveryHint(context.Context, *User, string, string) error diff --git a/types.go b/types.go index 447f32e23da5710ca682d1957e7834c99f390cae..68a3b526bc829893ab9f5b55c421f281a594f053 100644 --- a/types.go +++ b/types.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/tstranex/u2f" ) // Some considerations about the model design: @@ -31,6 +33,8 @@ type User struct { AppSpecificPasswords []*AppSpecificPasswordInfo `json:"app_specific_passwords,omitempty"` + U2FRegistrations []*u2f.Registration `json:"u2f_registrations"` + Resources []*Resource `json:"resources,omitempty"` }