Commit bcdecbe5 authored by ale's avatar ale

Add the actual account management logic

The result is a functional server, altough there aren't nearly enough comments.
parent 6e9218bf
package accountserver
import (
"context"
"errors"
"git.autistici.org/ai3/go-common/pwhash"
sso "git.autistici.org/id/go-sso"
"git.autistici.org/id/keystore/userenckey"
)
// Backend user database interface.
//
// All methods share similar semantics: Get methods will return nil if
// the requested object is not found, and only return an error in case
// of trouble reaching the backend itself.
//
// The backend enforces strict public/private data separation by
// having Get methods return public objects (as defined in types.go),
// and using specialized methods to modify the private
// (authentication-related) attributes.
//
// We might add more sophisticated resource query methods later, as
// admin-level functionality.
//
type Backend interface {
GetUser(context.Context, string) (*User, error)
GetResource(context.Context, string, string) (*Resource, error)
UpdateResource(context.Context, string, *Resource) error
SetUserPassword(context.Context, *User, string) error
SetResourcePassword(context.Context, string, *Resource, string) error
GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error
SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
DeleteApplicationSpecificPassword(context.Context, *User, string) error
}
// AccountService contains the business logic and functionality of the
// user accounts service.
type AccountService struct {
backend Backend
validator sso.Validator
ssoService string
ssoGroups []string
ssoAdminGroup string
}
func NewAccountService(backend Backend, ssoValidator sso.Validator, ssoService string, ssoGroups []string, ssoAdminGroup string) *AccountService {
return &AccountService{
backend: backend,
validator: ssoValidator,
ssoService: ssoService,
ssoGroups: ssoGroups,
ssoAdminGroup: ssoAdminGroup,
}
}
func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
for _, g := range tkt.Groups {
if g == s.ssoAdminGroup {
return true
}
}
return false
}
var (
ErrUnauthorized = errors.New("unauthorized")
ErrUserNotFound = errors.New("user not found")
ErrResourceNotFound = errors.New("resource not found")
)
func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken string) (*User, error) {
// First, check that the username matches the SSO ticket
// username (or that the SSO ticket has admin permissions).
tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
if err != nil {
return nil, ErrUnauthorized
}
// Requests are allowed if the SSO ticket corresponds to an admin, or if
// it identifies the same user that we're querying.
if !s.isAdmin(tkt) && tkt.User != username {
return nil, ErrUnauthorized
}
user, err := s.backend.GetUser(ctx, username)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
type RequestBase struct {
Username string `json:"username"`
SSO string `json:"sso"`
}
type GetUserRequest struct {
RequestBase
}
func (s *AccountService) GetUser(ctx context.Context, req *GetUserRequest) (*User, error) {
return s.authorizeUser(ctx, req.Username, req.SSO)
}
var errResourceNotFound = errors.New("resource not found")
func (s *AccountService) setResourceStatus(ctx context.Context, username, resourceID, status string) error {
r, err := s.backend.GetResource(ctx, username, resourceID)
if err != nil {
return errResourceNotFound
}
r.Status = status
return s.backend.UpdateResource(ctx, username, r)
}
type DisableResourceRequest struct {
RequestBase
ResourceID string `json:"resource_id"`
}
func (s *AccountService) DisableResource(ctx context.Context, req *DisableResourceRequest) error {
if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
return err
}
return s.setResourceStatus(ctx, req.Username, req.ResourceID, ResourceStatusInactive)
}
type EnableResourceRequest struct {
RequestBase
ResourceID string `json:"resource_id"`
}
func (s *AccountService) EnableResource(ctx context.Context, req *EnableResourceRequest) error {
if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
return err
}
return s.setResourceStatus(ctx, req.Username, req.ResourceID, ResourceStatusActive)
}
type ChangeUserPasswordRequest struct {
RequestBase
OldPassword string `json:"old_password"`
Password string `json:"password"`
}
func (s *AccountService) ChangeUserPassword(ctx context.Context, req *ChangeUserPasswordRequest) error {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
return err
}
if err := s.updateUserEncryptionKeys(ctx, user, req.OldPassword, req.Password, UserEncryptionKeyMainID); err != nil {
return err
}
// Set the encrypted password attribute on the user and email resources.
encPass := pwhash.Encrypt(req.Password)
if err := s.backend.SetUserPassword(ctx, user, encPass); err != nil {
return err
}
for _, r := range user.GetResourcesByType(ResourceTypeEmail) {
if err := s.backend.SetResourcePassword(ctx, user.Name, r, encPass); err != nil {
return err
}
}
return nil
}
func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, user *User, curPassword, newPassword, keyID string) error {
// Re-encrypt the user encryption key with the new password.
keys, err := s.backend.GetUserEncryptionKeys(ctx, user)
if err != nil {
return err
}
keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
if err != nil {
return err
}
return s.backend.SetUserEncryptionKeys(ctx, user, keys)
}
// 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
}
encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword))
if err != nil {
return nil, err
}
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
}
type CreateApplicationSpecificPasswordRequest struct {
RequestBase
Password string `json:"password"` // User password
Service string `json:"service"`
Comment string `json:"comment"`
}
type CreateApplicationSpecificPasswordResponse struct {
Password string `json:"password"`
}
func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
return nil, err
}
// Create a new application-specific password and set it in
// the database. We don't need to update the User object as
// we're not reusing it.
asp := &AppSpecificPasswordInfo{
ID: randomAppSpecificPasswordID(),
Service: req.Service,
Comment: req.Comment,
}
password := randomAppSpecificPassword()
encPass := pwhash.Encrypt(password)
if err := s.backend.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil {
return nil, err
}
// Create or update the user encryption key associated with
// this password. The user encryption key IDs for ASPs all
// have an 'asp_' prefix, followed by the ASP ID.
keyID := "asp_" + asp.ID
if err := s.updateUserEncryptionKeys(ctx, user, req.Password, password, keyID); err != nil {
return nil, err
}
return &CreateApplicationSpecificPasswordResponse{
Password: password,
}, nil
}
type DeleteApplicationSpecificPasswordRequest struct {
RequestBase
AspID string `json:"asp_id"`
}
func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, req *DeleteApplicationSpecificPasswordRequest) error {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
return err
}
if err := s.backend.DeleteApplicationSpecificPassword(ctx, user, req.AspID); err != nil {
return err
}
// Delete the user encryption key associated with this
// password (we're going to find it via its ID).
keys, err := s.backend.GetUserEncryptionKeys(ctx, user)
if err != nil {
return err
}
if len(keys) == 0 {
return nil
}
aspKeyID := "asp_" + req.AspID
var newKeys []*UserEncryptionKey
for _, k := range keys {
if k.ID != aspKeyID {
newKeys = append(newKeys, k)
}
}
return s.backend.SetUserEncryptionKeys(ctx, user, newKeys)
}
type ChangeResourcePasswordRequest struct {
RequestBase
ResourceID string `json:"resource_id"`
Password string `json:"password"`
}
func (s *AccountService) ChangeResourcePassword(ctx context.Context, req *ChangeResourcePasswordRequest) error {
_, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
return err
}
r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID)
if err != nil {
return err
}
encPass := pwhash.Encrypt(req.Password)
return s.backend.SetResourcePassword(ctx, req.Username, r, encPass)
}
type MoveResourceRequest struct {
RequestBase
ResourceID string `json:"resource_id"`
Shard string `json:"shard"`
}
type MoveResourceResponse struct {
MovedIDs []string `json:"moved_ids"`
}
func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequest) (*MoveResourceResponse, error) {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
return nil, err
}
// Collect all related resources, as they should all be moved at once.
r, err := s.backend.GetResource(ctx, req.Username, req.ResourceID)
if err != nil {
return nil, err
}
var resources []*Resource
if r.Group != "" {
for _, r := range user.GetResourcesByGroup(r.Group) {
resources = append(resources, r)
}
} else {
resources = []*Resource{r}
}
var resp MoveResourceResponse
for _, r := range resources {
r.Shard = req.Shard
if err := s.backend.UpdateResource(ctx, req.Username, r); err != nil {
return nil, err
}
resp.MovedIDs = append(resp.MovedIDs, r.ID)
}
return &resp, nil
}
func randomAppSpecificPassword() string {
return "haha"
}
func randomAppSpecificPasswordID() string {
return "1234"
}
package backend
import (
"errors"
"strings"
"git.autistici.org/ai3/accountserver"
)
type appSpecificPassword struct {
accountserver.AppSpecificPasswordInfo
Password string
}
func (p *appSpecificPassword) Encode() string {
return strings.Join([]string{
p.Service,
p.Password,
p.Comment,
}, ":")
}
func newAppSpecificPassword(info accountserver.AppSpecificPasswordInfo, pw string) *appSpecificPassword {
return &appSpecificPassword{
AppSpecificPasswordInfo: info,
Password: pw,
}
}
func parseAppSpecificPassword(asp string) (*appSpecificPassword, error) {
parts := strings.Split(asp, ":")
if len(parts) != 3 {
return nil, errors.New("badly encoded app-specific password")
}
return newAppSpecificPassword(accountserver.AppSpecificPasswordInfo{
Service: parts[0],
Comment: parts[2],
}, parts[1]), nil
}
func decodeAppSpecificPasswords(values []string) []*appSpecificPassword {
var out []*appSpecificPassword
for _, value := range values {
if asp, err := parseAppSpecificPassword(value); err == nil {
out = append(out, asp)
}
}
return out
}
func encodeAppSpecificPasswords(asps []*appSpecificPassword) []string {
var out []string
for _, asp := range asps {
out = append(out, asp.Encode())
}
return out
}
func getASPInfo(asps []*appSpecificPassword) []*accountserver.AppSpecificPasswordInfo {
var out []*accountserver.AppSpecificPasswordInfo
for _, asp := range asps {
out = append(out, &asp.AppSpecificPasswordInfo)
}
return out
}
......@@ -30,9 +30,16 @@ type LDAPBackend struct {
resourceQueries map[string]*queryConfig
}
const ldapPoolSize = 20
// NewLDAPBackend initializes an LDAPBackend object with the given LDAP
// connection pool.
func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend {
func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) {
pool, err := ldaputil.NewConnectionPool(uri, bindDN, bindPw, ldapPoolSize)
if err != nil {
return nil, err
}
return &LDAPBackend{
conn: pool,
userQuery: mustCompileQueryConfig(&queryConfig{
......@@ -79,7 +86,7 @@ func NewLDAPBackend(pool *ldaputil.ConnectionPool, base string) *LDAPBackend {
Scope: "one",
}),
},
}
}, nil
}
func replaceVars(s string, vars map[string]string) string {
......@@ -320,6 +327,10 @@ func databaseResourceToLDAP(r *accountserver.Resource) []ldap.PartialAttribute {
}
}
type ldapUserData struct {
dn string
}
func newUser(entry *ldap.Entry) (*accountserver.User, error) {
user := &accountserver.User{
Name: entry.GetAttributeValue("uid"),
......@@ -330,9 +341,18 @@ func newUser(entry *ldap.Entry) (*accountserver.User, error) {
if user.Lang == "" {
user.Lang = "en"
}
user.Opaque = &ldapUserData{dn: entry.DN}
return user, nil
}
func getUserDN(user *accountserver.User) string {
lu, ok := user.Opaque.(*ldapUserData)
if !ok {
panic("no ldap user data")
}
return lu.dn
}
// GetUser returns a user.
func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountserver.User, error) {
// First of all, find the main user object, and just that one.
......@@ -366,7 +386,7 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
// them on the main User object.
if isObjectClass(entry, "virtualMailUser") {
user.PasswordRecoveryHint = entry.GetAttributeValue("recoverQuestion")
setAppSpecificPasswords(user, entry.GetAttributeValues("appSpecificPassword"))
user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues("appSpecificPassword")))
}
// Parse the resource and add it to the User.
if r, err := parseLdapResource(entry); err == nil {
......@@ -380,6 +400,122 @@ func (b *LDAPBackend) GetUser(ctx context.Context, username string) (*accountser
return user, nil
}
func singleAttributeQuery(dn, attribute string) *ldap.SearchRequest {
return ldap.NewSearchRequest(
dn,
ldap.ScopeBaseObject,
ldap.NeverDerefAliases,
0,
0,
false,
"(objectClass=*)",
[]string{attribute},
nil,
)
}
func (b *LDAPBackend) readAttributeValues(ctx context.Context, dn, attribute string) []string {
req := singleAttributeQuery(dn, attribute)
result, err := b.conn.Search(ctx, req)
if err != nil {
return nil
}
if len(result.Entries) < 1 {
return nil
}
return result.Entries[0].GetAttributeValues(attribute)
}
func (b *LDAPBackend) readAttributeValue(ctx context.Context, dn, attribute string) string {
req := singleAttributeQuery(dn, attribute)
result, err := b.conn.Search(ctx, req)
if err != nil {
return ""
}
if len(result.Entries) < 1 {
return ""
}
return result.Entries[0].GetAttributeValue(attribute)
}
func (b *LDAPBackend) SetUserPassword(ctx context.Context, user *accountserver.User, encryptedPassword string) error {
mod := ldap.NewModifyRequest(getUserDN(user))
mod.Replace("userPassword", []string{encryptedPassword})
return b.conn.Modify(ctx, mod)
}
func (b *LDAPBackend) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) {
rawKeys := b.readAttributeValues(ctx, getUserDN(user), "storageEncryptionKey")
return accountserver.DecodeUserEncryptionKeys(rawKeys), nil
}
func (b *LDAPBackend) SetUserEncryptionKeys(ctx context.Context, user *accountserver.User, keys []*accountserver.UserEncryptionKey) error {
mod := ldap.NewModifyRequest(getUserDN(user))
if user.HasEncryptionKeys {
mod.Replace("storageEncryptionKey", accountserver.EncodeUserEncryptionKeys(keys))
} else {
mod.Add("storageEncryptionKey", accountserver.EncodeUserEncryptionKeys(keys))
}
return b.conn.Modify(ctx, mod)
}
func (b *LDAPBackend) SetApplicationSpecificPassword(ctx context.Context, user *accountserver.User, info *accountserver.AppSpecificPasswordInfo, encryptedPassword string) error {
emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
if emailRsrc == nil {
return errors.New("no email resource")
}
emailDN := getResourceDN(emailRsrc)
asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
var outASPs []*appSpecificPassword
for _, asp := range asps {
if asp.ID != info.ID {
outASPs = append(outASPs, asp)
}
}
outASPs = append(outASPs, newAppSpecificPassword(*info, encryptedPassword))
mod := ldap.NewModifyRequest(emailDN)
if len(asps) > 0 {
mod.Replace("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
} else {
mod.Add("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
}
return b.conn.Modify(ctx, mod)
}
func (b *LDAPBackend) DeleteApplicationSpecificPassword(ctx context.Context, user *accountserver.User, id string) error {
emailRsrc := user.GetSingleResourceByType(accountserver.ResourceTypeEmail)
if emailRsrc == nil {
return errors.New("no email resource")
}
emailDN := getResourceDN(emailRsrc)
asps := decodeAppSpecificPasswords(b.readAttributeValues(ctx, emailDN, "appSpecificPassword"))
var outASPs []*appSpecificPassword
for _, asp := range asps {
if asp.ID != id {
outASPs = append(outASPs, asp)
}
}
mod := ldap.NewModifyRequest(emailDN)
if len(outASPs) == 0 {
mod.Delete("appSpecificPassword", encodeAppSpecificPasswords(asps))
} else if len(asps) > 0 {
mod.Replace("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
} else {
mod.Add("appSpecificPassword", encodeAppSpecificPasswords(outASPs))
}
return b.conn.Modify(ctx, mod)
}
func (b *LDAPBackend) SetResourcePassword(ctx context.Context, _ string, r *accountserver.Resource, encryptedPassword string) error {
mod := ldap.NewModifyRequest(getResourceDN(r))
mod.Replace("userPassword", []string{encryptedPassword})
return b.conn.Modify(ctx, mod)
}
func parseResourceID(resourceID string) (string, string) {
parts := strings.SplitN(resourceID, "/", 2)
return parts[0], parts[1]
......@@ -405,22 +541,12 @@ func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID stri
return nil, err
}
r, err := parseLdapResource(result.Entries[0])
if err != nil {
return nil, err
}
r.SetBackendHandle(&ldapObjectData{
dn: result.Entries[0].DN,
original: r.Copy(),
})
return r, nil
return parseLdapResource(result.Entries[0])
}
// UpdateResource updates a LDAP-backed resource that was obtained by a previous GetResource call.
func (b *LDAPBackend) UpdateResource(ctx context.Context, username string, r *accountserver.Resource) error {
lo, ok := r.GetBackendHandle().(*ldapObjectData)
func (b *LDAPBackend) UpdateResource(ctx context.Context, _ string, r *accountserver.Resource) error {
lo, ok := r.Opaque.(*ldapObjectData)