Commit 5ffe2e08 authored by ale's avatar ale

Add validators for request fields

This includes a number of validators meant to support the creation of
new users and resources (for instance by checking for resource ID
uniqueness etc).
parent 8564652c
......@@ -39,6 +39,8 @@ type Backend interface {
DeleteApplicationSpecificPassword(context.Context, *User, string) error
SetUserTOTPSecret(context.Context, *User, string) error
DeleteUserTOTPSecret(context.Context, *User) error
HasAnyResource(context.Context, []string) (bool, error)
}
// AccountService implements the business logic and high-level
......@@ -50,16 +52,37 @@ type AccountService struct {
ssoService string
ssoGroups []string
ssoAdminGroup string
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 NewAccountService(backend Backend, ssoValidator sso.Validator, ssoService string, ssoGroups []string, ssoAdminGroup string) *AccountService {
return &AccountService{
func newAccountServiceWithSSO(backend Backend, config *Config, ssoValidator sso.Validator) *AccountService {
s := &AccountService{
backend: backend,
validator: ssoValidator,
ssoService: ssoService,
ssoGroups: ssoGroups,
ssoAdminGroup: ssoAdminGroup,
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),
}
return s
}
func (s *AccountService) isAdmin(tkt *sso.Ticket) bool {
......@@ -77,6 +100,29 @@ var (
ErrResourceNotFound = errors.New("resource not found")
)
func (s *AccountService) authorizeAdmin(ctx context.Context, 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 := s.backend.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, username, ssoToken string) (*User, error) {
// First, check that the username matches the SSO ticket
// username (or that the SSO ticket has admin permissions).
......@@ -101,19 +147,38 @@ func (s *AccountService) authorizeUser(ctx context.Context, username, ssoToken s
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, username, ssoToken, password string) (*User, error) {
// TODO: call out to the auth-server?
return s.authorizeUser(ctx, 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, req *GetUserRequest) (*User, error) {
return s.authorizeUser(ctx, 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, username, resourceID, status string) error {
r, err := s.backend.GetResource(ctx, username, resourceID)
if err != nil {
......@@ -134,6 +199,7 @@ type DisableResourceRequest struct {
ResourceID string `json:"resource_id"`
}
// DisableResource disables a resource belonging to the user.
func (s *AccountService) DisableResource(ctx context.Context, req *DisableResourceRequest) error {
if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
return err
......@@ -146,6 +212,7 @@ type EnableResourceRequest struct {
ResourceID string `json:"resource_id"`
}
// EnableResource enables a resource belonging to the user.
func (s *AccountService) EnableResource(ctx context.Context, req *EnableResourceRequest) error {
if _, err := s.authorizeUser(ctx, req.Username, req.SSO); err != nil {
return err
......@@ -154,23 +221,21 @@ func (s *AccountService) EnableResource(ctx context.Context, req *EnableResource
}
type ChangeUserPasswordRequest struct {
RequestBase
CurPassword string `json:"cur_password"`
Password string `json:"password"`
PrivilegedRequestBase
Password string `json:"password"`
}
func (r *ChangeUserPasswordRequest) Validate() error {
if r.CurPassword == "" {
return errors.New("empty 'cur_password' attribute")
}
if r.Password == "" {
return errors.New("empty 'password' attribute")
}
return nil
}
// 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, req *ChangeUserPasswordRequest) error {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword)
if err != nil {
return err
}
......@@ -286,16 +351,12 @@ func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyI
}
type CreateApplicationSpecificPasswordRequest struct {
RequestBase
CurPassword string `json:"cur_password"` // User password.
Service string `json:"service"`
Comment string `json:"comment"`
PrivilegedRequestBase
Service string `json:"service"`
Comment string `json:"comment"`
}
func (r *CreateApplicationSpecificPasswordRequest) Validate() error {
if r.CurPassword == "" {
return errors.New("empty 'cur_password' attribute")
}
if r.Service == "" {
return errors.New("empty 'service' attribute")
}
......@@ -306,8 +367,10 @@ 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, req *CreateApplicationSpecificPasswordRequest) (*CreateApplicationSpecificPasswordResponse, error) {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
user, err := s.authorizeUserWithPassword(ctx, req.Username, req.SSO, req.CurPassword)
if err != nil {
return nil, err
}
......@@ -355,6 +418,8 @@ type DeleteApplicationSpecificPasswordRequest struct {
AspID string `json:"asp_id"`
}
// DeleteApplicationSpecificPassword destroys an application-specific
// password, identified by its unique ID.
func (s *AccountService) DeleteApplicationSpecificPassword(ctx context.Context, req *DeleteApplicationSpecificPasswordRequest) error {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
......@@ -397,6 +462,9 @@ func (r *ChangeResourcePasswordRequest) Validate() error {
return nil
}
// 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, req *ChangeResourcePasswordRequest) error {
_, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
......@@ -432,8 +500,12 @@ 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, req *MoveResourceRequest) (*MoveResourceResponse, error) {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
user, err := s.authorizeAdmin(ctx, req.Username, req.SSO)
if err != nil {
return nil, err
}
......@@ -464,29 +536,49 @@ func (s *AccountService) MoveResource(ctx context.Context, req *MoveResourceRequ
type EnableOTPRequest struct {
RequestBase
TOTPSecret string `json:"totp_secret"`
}
func (r *EnableOTPRequest) Validate() 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, req *EnableOTPRequest) (*EnableOTPResponse, error) {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
return nil, err
}
if err = req.Validate(); err != nil {
return nil, newRequestError(err)
}
// Replace or initialize the TOTP secret.
totpSecret, err := generateTOTPSecret()
if err != nil {
return nil, err
if req.TOTPSecret == "" {
req.TOTPSecret, err = generateTOTPSecret()
if err != nil {
return nil, err
}
}
if err := s.backend.SetUserTOTPSecret(ctx, user, totpSecret); err != nil {
if err := s.backend.SetUserTOTPSecret(ctx, user, req.TOTPSecret); err != nil {
return nil, newBackendError(err)
}
return &EnableOTPResponse{
TOTPSecret: totpSecret,
TOTPSecret: req.TOTPSecret,
}, nil
}
......@@ -494,6 +586,7 @@ type DisableOTPRequest struct {
RequestBase
}
// DisableOTP disables two-factor authentication for a user.
func (s *AccountService) DisableOTP(ctx context.Context, req *DisableOTPRequest) error {
user, err := s.authorizeUser(ctx, req.Username, req.SSO)
if err != nil {
......
......@@ -66,6 +66,10 @@ func (b *fakeBackend) DeleteUserTOTPSecret(_ context.Context, user *User) error
return nil
}
func (b *fakeBackend) HasAnyResource(_ context.Context, rsrcs []string) (bool, error) {
return false, nil
}
const testAdminGroupName = "admins"
type fakeValidator struct {
......@@ -110,8 +114,16 @@ func createFakeBackend() *fakeBackend {
return fb
}
func testConfig() *Config {
var c Config
c.SSO.Domain = "mydomain"
c.SSO.Service = "service/"
c.SSO.AdminGroup = testAdminGroupName
return &c
}
func TestService_GetUser(t *testing.T) {
svc := NewAccountService(createFakeBackend(), &fakeValidator{}, "service/", nil, testAdminGroupName)
svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{})
req := &GetUserRequest{
RequestBase: RequestBase{
......@@ -129,7 +141,7 @@ func TestService_GetUser(t *testing.T) {
}
func TestService_Auth(t *testing.T) {
svc := NewAccountService(createFakeBackend(), &fakeValidator{"adminuser"}, "service/", nil, "admins")
svc := newAccountServiceWithSSO(createFakeBackend(), testConfig(), &fakeValidator{"adminuser"})
for _, td := range []struct {
sso string
......@@ -160,15 +172,17 @@ func TestService_Auth(t *testing.T) {
func TestService_ChangePassword(t *testing.T) {
fb := createFakeBackend()
svc := NewAccountService(fb, &fakeValidator{}, "service/", nil, testAdminGroupName)
svc := newAccountServiceWithSSO(fb, testConfig(), &fakeValidator{})
req := &ChangeUserPasswordRequest{
RequestBase: RequestBase{
Username: "testuser",
SSO: "testuser",
PrivilegedRequestBase: PrivilegedRequestBase{
RequestBase: RequestBase{
Username: "testuser",
SSO: "testuser",
},
CurPassword: "cur",
},
CurPassword: "cur",
Password: "password",
Password: "password",
}
err := svc.ChangeUserPassword(context.TODO(), req)
if err != nil {
......@@ -176,6 +190,9 @@ func TestService_ChangePassword(t *testing.T) {
}
if _, ok := fb.passwords["testuser"]; !ok {
t.Fatal("password was not set on the backend")
t.Error("password was not set on the backend")
}
if len(fb.encryptionKeys["testuser"]) != 1 {
t.Errorf("no encryption keys were set")
}
}
......@@ -23,11 +23,17 @@ type ldapConn interface {
}
// LDAPBackend is the interface to an LDAP-backed user database.
//
// We keep a set of LDAP queries for each resource type, each having a
// "resource" query to return a specific resource belonging to a user,
// and a "presence" query that checks for existence of a resource for
// all users.
type LDAPBackend struct {
conn ldapConn
userQuery *queryConfig
userResourceQueries []*queryConfig
resourceQueries map[string]*queryConfig
presenceQueries map[string]*queryConfig
}
const ldapPoolSize = 20
......@@ -86,6 +92,33 @@ func NewLDAPBackend(uri, bindDN, bindPw, base string) (*LDAPBackend, error) {
Scope: "one",
}),
},
presenceQueries: map[string]*queryConfig{
accountserver.ResourceTypeEmail: mustCompileQueryConfig(&queryConfig{
Base: "ou=People," + base,
Filter: "(&(objectClass=virtualMailUser)(mail=${resource}))",
Scope: "sub",
}),
accountserver.ResourceTypeWebsite: mustCompileQueryConfig(&queryConfig{
Base: "ou=People," + base,
Filter: "(|(&(objectClass=subSite)(alias=${resource}))(&(objectClass=virtualHost)(cn=${resource})))",
Scope: "sub",
}),
accountserver.ResourceTypeDAV: mustCompileQueryConfig(&queryConfig{
Base: "ou=People," + base,
Filter: "(&(objectClass=ftpAccount)(ftpname=${resource}))",
Scope: "sub",
}),
accountserver.ResourceTypeDatabase: mustCompileQueryConfig(&queryConfig{
Base: "ou=People," + base,
Filter: "(&(objectClass=dbMysql)(dbname=${resource}))",
Scope: "sub",
}),
accountserver.ResourceTypeMailingList: mustCompileQueryConfig(&queryConfig{
Base: "ou=Lists," + base,
Filter: "(&(objectClass=mailingList)(listName=${resource}))",
Scope: "one",
}),
},
}, nil
}
......@@ -548,6 +581,41 @@ func parseResourceID(resourceID string) (string, string) {
return parts[0], parts[1]
}
func (b *LDAPBackend) hasResource(ctx context.Context, resourceID string) (bool, error) {
resourceType, resourceName := parseResourceID(resourceID)
query, ok := b.presenceQueries[resourceType]
if !ok {
return false, errors.New("unsupported resource type")
}
// Make a quick LDAP search that only fetches the DN attribute.
result, err := b.conn.Search(ctx, query.searchRequest(map[string]string{
"resource": resourceName,
"type": resourceType,
}, []string{"dn"}))
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) {
return false, nil
}
return false, err
}
if len(result.Entries) == 0 {
return false, nil
}
return true, nil
}
// HasAnyResource returns true if any of the specified resources exists.
func (b *LDAPBackend) HasAnyResource(ctx context.Context, resourceIDs []string) (bool, error) {
for _, resourceID := range resourceIDs {
has, err := b.hasResource(ctx, resourceID)
if err != nil || has {
return has, err
}
}
return false, nil
}
// GetResource returns a ResourceWrapper, as part of a read-modify-update transaction.
func (b *LDAPBackend) GetResource(ctx context.Context, username, resourceID string) (*accountserver.Resource, error) {
resourceType, resourceName := parseResourceID(resourceID)
......
......@@ -9,7 +9,6 @@ import (
"git.autistici.org/ai3/accountserver"
"git.autistici.org/ai3/go-common/serverutil"
"git.autistici.org/id/go-sso"
"gopkg.in/yaml.v2"
"git.autistici.org/ai3/accountserver/backend"
......@@ -18,10 +17,10 @@ import (
var (
addr = flag.String("addr", ":4040", "tcp `address` to listen on")
configFile = flag.String("config", "/etc/authserver/config.yml", "configuration `file`")
configFile = flag.String("config", "/etc/accountserver/config.yml", "configuration `file`")
)
type Config struct {
type config struct {
LDAP struct {
URI string `yaml:"uri"`
BindDN string `yaml:"bind_dn"`
......@@ -29,17 +28,11 @@ type Config struct {
BindPwFile string `yaml:"bind_pw_file"`
BaseDN string `yaml:"base_dn"`
} `yaml:"ldap"`
SSO struct {
PublicKeyFile string `yaml:"public_key"`
Domain string `yaml:"domain"`
Service string `yaml:"service"`
Groups []string `yaml:"groups"`
AdminGroup string `yaml:"admin_group"`
} `yaml:"sso"`
ServerConfig *serverutil.ServerConfig `yaml:"http_server"`
AccountServerConfig *accountserver.Config `yaml:",inline"`
ServerConfig *serverutil.ServerConfig `yaml:"http_server"`
}
func (c *Config) Validate() error {
func (c *config) validate() error {
if c.LDAP.URI == "" {
return errors.New("empty ldap.uri")
}
......@@ -49,39 +42,39 @@ func (c *Config) Validate() error {
if (c.LDAP.BindPwFile == "" && c.LDAP.BindPw == "") || (c.LDAP.BindPwFile != "" && c.LDAP.BindPw != "") {
return errors.New("only one of ldap.bind_pw_file or ldap.bind_pw must be set")
}
if c.SSO.PublicKeyFile == "" {
if c.AccountServerConfig.SSO.PublicKeyFile == "" {
return errors.New("empty sso.public_key")
}
if c.SSO.Domain == "" {
if c.AccountServerConfig.SSO.Domain == "" {
return errors.New("empty sso.domain")
}
if c.SSO.Service == "" {
if c.AccountServerConfig.SSO.Service == "" {
return errors.New("empty sso.service")
}
return nil
}
func loadConfig(path string) (*Config, error) {
func loadConfig(path string) (*config, error) {
// Read YAML config.
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
var c config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
if err := config.Validate(); err != nil {
if err := c.validate(); err != nil {
return nil, err
}
return &config, nil
return &c, nil
}
func getBindPw(config *Config) (string, error) {
func getBindPw(c *config) (string, error) {
// Read the bind password.
bindPw := config.LDAP.BindPw
if config.LDAP.BindPwFile != "" {
pwData, err := ioutil.ReadFile(config.LDAP.BindPwFile)
bindPw := c.LDAP.BindPw
if c.LDAP.BindPwFile != "" {
pwData, err := ioutil.ReadFile(c.LDAP.BindPwFile)
if err != nil {
return "", err
}
......@@ -91,14 +84,6 @@ func getBindPw(config *Config) (string, error) {
return bindPw, nil
}
func initSSO(config *Config) (sso.Validator, error) {
pkey, err := ioutil.ReadFile(config.SSO.PublicKeyFile)
if err != nil {
return nil, err
}
return sso.NewValidator(pkey, config.SSO.Domain)
}
func main() {
log.SetFlags(0)
flag.Parse()
......@@ -123,17 +108,10 @@ func main() {
log.Fatal(err)
}
validator, err := initSSO(config)
service, err := accountserver.NewAccountService(be, config.AccountServerConfig)
if err != nil {
log.Fatal(err)
}
service := accountserver.NewAccountService(
be,
validator,
config.SSO.Service,
config.SSO.Groups,
config.SSO.AdminGroup,
)
as := server.New(service)
......
package accountserver
import (
"io/ioutil"
"git.autistici.org/id/go-sso"
)
type Config struct {
ForbiddenUsernames []string `yaml:"forbidden_usernames"`
AvailableDomains map[string][]string `yaml:"available_domains"`
SSO struct {
PublicKeyFile string `yaml:"public_key"`
Domain string `yaml:"domain"`
Service string `yaml:"service"`
Groups []string `yaml:"groups"`
AdminGroup string `yaml:"admin_group"`
} `yaml:"sso"`
}
func (c *Config) domainBackend() domainBackend {
b := &staticDomainBackend{sets: make(map[string]stringSet)}
for kind, list := range c.AvailableDomains {
b.sets[kind] = newStringSetFromList(list)
}
return b
}
func (c *Config) validationConfig() *validationConfig {
return &validationConfig{
forbiddenUsernames: newStringSetFromList(c.ForbiddenUsernames),
}
}
func (c *Config) ssoValidator() (sso.Validator, error) {
pkey, err := ioutil.ReadFile(c.SSO.PublicKeyFile)
if err != nil {
return nil, err
}
return sso.NewValidator(pkey, c.SSO.Domain)
}
package accountserver
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"golang.org/x/net/publicsuffix"
)
// A domainBackend manages the list of domains users are allowed to request services on.
type domainBackend interface {
GetAvailableDomains(context.Context, string) []string
IsAvailableDomain(context.Context, string, string) bool
}
// The checkBackend verifies if specific resources already exist or are available.
type checkBackend interface {
HasAnyResource(context.Context, []string) (bool, error)
}
type validationConfig struct {
forbiddenUsernames stringSet