Select Git revision
Forked from
ai3 / tools / acmeserver
Source project has a limited visibility.
actions.go 18.57 KiB
package accountserver
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"git.autistici.org/ai3/go-common/pwhash"
"git.autistici.org/id/go-sso"
"git.autistici.org/id/keystore/userenckey"
"github.com/pquerna/otp/totp"
)
// Backend user database interface.
//
// We are using a transactional interface even if the actual backend
// (LDAP) does not support atomic transactions, just so it is easy to
// add more backends in the future (like SQL).
type Backend interface {
NewTransaction() (TX, error)
}
// TX represents a single transaction with the backend and offers a
// high-level data management abstraction.
//
// 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 TX interface {
Commit(context.Context) error
GetResource(context.Context, ResourceID) (*Resource, error)
UpdateResource(context.Context, *Resource) error
SetResourcePassword(context.Context, *Resource, string) error
GetUser(context.Context, string) (*User, error)
SetUserPassword(context.Context, *User, string) error
GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error)
SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error
SetUserEncryptionPublicKey(context.Context, *User, []byte) error
SetApplicationSpecificPassword(context.Context, *User, *AppSpecificPasswordInfo, string) error
DeleteApplicationSpecificPassword(context.Context, *User, string) error
SetUserTOTPSecret(context.Context, *User, string) error
DeleteUserTOTPSecret(context.Context, *User) error
HasAnyResource(context.Context, []FindResourceRequest) (bool, error)
}
// FindResourceRequest contains parameters for searching a resource by name.
type FindResourceRequest struct {
Type string
Name string
}
// AccountService implements the business logic and high-level
// functionality of the user accounts management service.
type AccountService struct {
validator sso.Validator
ssoService string
ssoGroups []string
ssoAdminGroup string
passwordValidator ValidatorFunc
dataValidators map[string]ValidatorFunc
adminDataValidators map[string]ValidatorFunc
}
func NewAccountService(backend Backend, config *Config) (*AccountService, error) {
ssoValidator, err := config.ssoValidator()
if err != nil {
return nil, err
}
return newAccountServiceWithSSO(backend, config, ssoValidator), nil
}
func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService {
s := &AccountService{
validator: ssoValidator,
ssoService: config.SSO.Service,
ssoGroups: config.SSO.Groups,
ssoAdminGroup: config.SSO.AdminGroup,
}
validationConfig := config.validationConfig()
domainBackend := config.domainBackend()
s.dataValidators = map[string]ValidatorFunc{
ResourceTypeEmail: validHostedEmail(validationConfig, domainBackend, backend),
ResourceTypeMailingList: validHostedMailingList(validationConfig, domainBackend, backend),
}
s.passwordValidator = validPassword(validationConfig)
return s
}
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) authorizeAdmin(ctx context.Context, tx TX, username, ssoToken string) (*User, error) {
// Validate the SSO ticket.
tkt, err := s.validator.Validate(ssoToken, "", s.ssoService, s.ssoGroups)
if err != nil {
return nil, newAuthError(err)
}
// 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) {
return nil, newAuthError(ErrUnauthorized)
}
user, err := tx.GetUser(ctx, username)
if err != nil {
return nil, newBackendError(err)
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
func (s *AccountService) authorizeUser(ctx context.Context, tx TX, 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, newAuthError(err)
}
// 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, newAuthError(ErrUnauthorized)
}
user, err := tx.GetUser(ctx, username)
if err != nil {
return nil, newBackendError(err)
}
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
// Extended version of authorizeUser that also directly checks the
// user password. Used for account-privileged operations related to
// credential manipulation.
func (s *AccountService) authorizeUserWithPassword(ctx context.Context, tx TX, username, ssoToken, password string) (*User, error) {
// TODO: call out to the auth-server?
return s.authorizeUser(ctx, tx, username, ssoToken)
}
// RequestBase contains parameters shared by all request types.
type RequestBase struct {
Username string `json:"username"`
SSO string `json:"sso"`
}
// PrivilegedRequestBase extends RequestBase with the user password,
// for privileged endpoints.
type PrivilegedRequestBase struct {
RequestBase
CurPassword string `json:"cur_password"`
}
type GetUserRequest struct {
RequestBase
}
// GetUser returns public information about a user.
func (s *AccountService) GetUser(ctx context.Context, tx TX, req *GetUserRequest) (*User, error) {
return s.authorizeUser(ctx, tx, req.Username, req.SSO)
}
// setResourceStatus sets the status of a single resource (shared
// logic between enable / disable resource methods).
func (s *AccountService) setResourceStatus(ctx context.Context, tx TX, username string, resourceID ResourceID, status string) error {
r, err := tx.GetResource(ctx, resourceID)
if err != nil {
return newBackendError(err)
}
if r == nil {
return ErrResourceNotFound
}
r.Status = status
if err := tx.UpdateResource(ctx, r); err != nil {
return newBackendError(err)
}
return nil
}
type DisableResourceRequest struct {
RequestBase
ResourceID ResourceID `json:"resource_id"`
}
// DisableResource disables a resource belonging to the user.
func (s *AccountService) DisableResource(ctx context.Context, tx TX, req *DisableResourceRequest) error {
if _, err := s.authorizeUser(ctx, tx, req.Username, req.SSO); err != nil {
return err
}
return s.setResourceStatus(ctx, tx, req.Username, req.ResourceID, ResourceStatusInactive)
}
type EnableResourceRequest struct {
RequestBase
ResourceID ResourceID `json:"resource_id"`
}
// EnableResource enables a resource belonging to the user.
func (s *AccountService) EnableResource(ctx context.Context, tx TX, req *EnableResourceRequest) error {
if _, err := s.authorizeUser(ctx, tx, req.Username, req.SSO); err != nil {
return err
}
return s.setResourceStatus(ctx, tx, req.Username, req.ResourceID, ResourceStatusActive)
}
type ChangeUserPasswordRequest struct {
PrivilegedRequestBase
Password string `json:"password"`
}
func (r *ChangeUserPasswordRequest) Validate(ctx context.Context, s *AccountService) error {
return s.passwordValidator(ctx, r.Password)
}
// ChangeUserPassword updates a user's password. It will also take
// care of re-encrypting the user encryption key, if present.
func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *ChangeUserPasswordRequest) error {
user, err := s.authorizeUserWithPassword(ctx, tx, req.Username, req.SSO, req.CurPassword)
if err != nil {
return err
}
if err = req.Validate(ctx, s); err != nil {
return newRequestError(err)
}
// If the user does not yet have an encryption key, generate one now.
if !user.HasEncryptionKeys {
err = s.initializeUserEncryptionKeys(ctx, tx, user, req.CurPassword)
} else {
err = s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID)
}
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 {
return newBackendError(err)
}
return nil
}
// 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()
if err != nil {
return err
}
// Encrypt the private key with the password.
enc, err := userenckey.Encrypt(priv, []byte(curPassword))
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 {
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)
if err != nil {
return newBackendError(err)
}
keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID)
if err != nil {
return newRequestError(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
}
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 {
PrivilegedRequestBase
Service string `json:"service"`
Comment string `json:"comment"`
}
func (r *CreateApplicationSpecificPasswordRequest) Validate(_ context.Context, _ *AccountService) error {
if r.Service == "" {
return errors.New("empty 'service' attribute")
}
return nil
}
type CreateApplicationSpecificPasswordResponse struct {
Password string `json:"password"`
}
// CreateApplicationSpecificPassword will generate a new
// application-specific password for the given service.
func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, tx TX, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
user, err := s.authorizeUserWithPassword(ctx, tx, req.Username, req.SSO, req.CurPassword)
if err != nil {
return nil, err
}
if err := req.Validate(ctx, s); err != nil {
return nil, newRequestError(err)
}
// No application-specific passwords unless 2FA is enabled.
if !user.Has2FA {
return nil, newRequestError(errors.New("2FA is not enabled for this user"))
}
// 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 := tx.SetApplicationSpecificPassword(ctx, user, asp, encPass); err != nil {
return nil, newBackendError(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.
if user.HasEncryptionKeys {
keyID := "asp_" + asp.ID
if err := s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, password, keyID); err != nil {
return nil, err
}
}
return &CreateApplicationSpecificPasswordResponse{
Password: password,
}, nil
}
type DeleteApplicationSpecificPasswordRequest struct {
RequestBase
AspID string `json:"asp_id"`
}
// DeleteApplicationSpecificPassword destroys an application-specific
// password, identified by its unique ID.
func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, tx TX, req *DeleteApplicationSpecificPasswordRequest) error {
user, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
if err != nil {
return err
}
if err = tx.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 := tx.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 tx.SetUserEncryptionKeys(ctx, user, newKeys)
}
type ChangeResourcePasswordRequest struct {
RequestBase
ResourceID ResourceID `json:"resource_id"`
Password string `json:"password"`
}
func (r *ChangeResourcePasswordRequest) Validate(ctx context.Context, s *AccountService) error {
return s.passwordValidator(ctx, r.Password)
}
// ChangeResourcePassword modifies the password associated with a
// specific resource. Resources that do not support this method should
// return an error from the backend.
func (s *AccountService) ChangeResourcePassword(ctx context.Context, tx TX, req *ChangeResourcePasswordRequest) error {
_, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
if err != nil {
return err
}
if err = req.Validate(ctx, s); err != nil {
return newRequestError(err)
}
r, err := tx.GetResource(ctx, req.ResourceID)
if err != nil {
return newBackendError(err)
}
if r == nil {
return ErrResourceNotFound
}
encPass := pwhash.Encrypt(req.Password)
if err := tx.SetResourcePassword(ctx, r, encPass); err != nil {
return newBackendError(err)
}
return nil
}
type MoveResourceRequest struct {
RequestBase
ResourceID ResourceID `json:"resource_id"`
Shard string `json:"shard"`
}
type MoveResourceResponse struct {
MovedIDs []string `json:"moved_ids"`
}
// MoveResource is an administrative operation to move resources
// between shards. Resources that are part of a group are moved all at
// once regardless of which individual ResourceID is provided as long
// as it belongs to the group.
func (s *AccountService) MoveResource(ctx context.Context, tx TX, req *MoveResourceRequest) (*MoveResourceResponse, error) {
user, err := s.authorizeAdmin(ctx, tx, req.Username, req.SSO)
if err != nil {
return nil, err
}
// Collect all related resources, as they should all be moved at once.
r, err := tx.GetResource(ctx, req.ResourceID)
if err != nil {
return nil, err
}
var resources []*Resource
if r.Group != "" {
resources = append(resources, user.GetResourcesByGroup(r.Group)...)
} else {
resources = []*Resource{r}
}
var resp MoveResourceResponse
for _, r := range resources {
r.Shard = req.Shard
if err := tx.UpdateResource(ctx, r); err != nil {
return nil, err
}
resp.MovedIDs = append(resp.MovedIDs, r.ID.String())
}
return &resp, nil
}
type EnableOTPRequest struct {
RequestBase
TOTPSecret string `json:"totp_secret"`
}
func (r *EnableOTPRequest) Validate(_ context.Context, _ *AccountService) error {
// TODO: the length here is bogus, replace with real value.
if r.TOTPSecret != "" && len(r.TOTPSecret) != 32 {
return errors.New("bad totp_secret value")
}
return nil
}
type EnableOTPResponse struct {
TOTPSecret string `json:"totp_secret"`
}
// EnableOTP enables OTP-based two-factor authentication for a
// user. The caller can generate the TOTP secret itself if needed
// (useful for UX that confirms that the user is able to login first),
// or it can let the server generate a new secret by passing an empty
// totp_secret.
func (s *AccountService) EnableOTP(ctx context.Context, tx TX, req *EnableOTPRequest) (*EnableOTPResponse, error) {
user, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
if err != nil {
return nil, err
}
if err = req.Validate(ctx, s); err != nil {
return nil, newRequestError(err)
}
// Replace or initialize the TOTP secret.
if req.TOTPSecret == "" {
req.TOTPSecret, err = generateTOTPSecret()
if err != nil {
return nil, err
}
}
if err := tx.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil {
return nil, newBackendError(err)
}
return &EnableOTPResponse{
TOTPSecret: req.TOTPSecret,
}, nil
}
type DisableOTPRequest struct {
RequestBase
}
// DisableOTP disables two-factor authentication for a user.
func (s *AccountService) DisableOTP(ctx context.Context, tx TX, req *DisableOTPRequest) error {
user, err := s.authorizeUser(ctx, tx, req.Username, req.SSO)
if err != nil {
return err
}
// Delete the TOTP secret (if present).
if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil {
return newBackendError(err)
}
return nil
}
const appSpecificPasswordLen = 64
func randomBase64(n int) string {
b := make([]byte, n/4*3)
_, err := rand.Read(b[:])
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b[:])
}
func randomAppSpecificPassword() string {
return randomBase64(appSpecificPasswordLen)
}
const appSpecificPasswordIDLen = 4
func randomAppSpecificPasswordID() string {
return randomBase64(appSpecificPasswordIDLen)
}
func generateTOTPSecret() (string, error) {
key, err := totp.Generate(totp.GenerateOpts{})
if err != nil {
return "", err
}
return key.Secret(), nil
}